Add privacy-first request location picker
This commit is contained in:
parent
3d320ce182
commit
b0abe5248c
|
|
@ -308,3 +308,255 @@ html {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 18px 48px rgb(23 37 31 / 0.10);
|
box-shadow: 0 18px 48px rgb(23 37 31 / 0.10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-location-workspace {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--color-base-300);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: var(--color-base-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-modes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-base-300);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 4.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-inline-start: 1px solid var(--color-base-300);
|
||||||
|
background: var(--color-base-100);
|
||||||
|
transition: color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode:first-child {
|
||||||
|
border-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode strong,
|
||||||
|
.request-location-mode small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode strong {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode small {
|
||||||
|
color: color-mix(in oklab, var(--color-base-content) 58%, transparent);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode:has(input:checked) {
|
||||||
|
z-index: 1;
|
||||||
|
background: color-mix(in oklab, var(--color-success) 9%, var(--color-base-100));
|
||||||
|
color: var(--color-success);
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode:has(input:focus-visible) {
|
||||||
|
outline: 3px solid color-mix(in oklab, var(--color-success) 38%, transparent);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(14rem, 17rem);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-map-shell {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-map {
|
||||||
|
min-height: 31rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-map[data-map-unavailable="true"] {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: color-mix(in oklab, var(--color-base-content) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-hidden-state {
|
||||||
|
display: none;
|
||||||
|
min-height: 16rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px dashed var(--color-base-300);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--color-base-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-controls {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--color-base-300);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--color-base-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-radius label {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-radius input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-radius span {
|
||||||
|
display: grid;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-base-300);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-radius label:has(input:checked) span {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
background: color-mix(in oklab, var(--color-success) 9%, var(--color-base-100));
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-radius label:has(input:focus-visible) span {
|
||||||
|
outline: 3px solid color-mix(in oklab, var(--color-success) 38%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-privacy,
|
||||||
|
.request-location-exact-warning,
|
||||||
|
.request-location-after-match {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem;
|
||||||
|
border: 1px solid var(--color-base-300);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-privacy {
|
||||||
|
border-color: color-mix(in oklab, var(--color-success) 36%, var(--color-base-300));
|
||||||
|
background: color-mix(in oklab, var(--color-success) 8%, var(--color-base-100));
|
||||||
|
color: color-mix(in oklab, var(--color-success) 85%, var(--color-base-content));
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-exact-warning {
|
||||||
|
border-color: color-mix(in oklab, var(--color-warning) 42%, var(--color-base-300));
|
||||||
|
background: color-mix(in oklab, var(--color-warning) 8%, var(--color-base-100));
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-privacy p,
|
||||||
|
.request-location-exact-warning p,
|
||||||
|
.request-location-after-match p {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
color: color-mix(in oklab, var(--color-base-content) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-exact-warning {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-manual {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--color-base-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-manual summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-workspace[data-location-mode="exact_public"] .request-location-radius,
|
||||||
|
.request-location-workspace[data-location-mode="exact_public"] .request-location-privacy {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-workspace[data-location-mode="exact_public"] .request-location-exact-warning {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-workspace[data-location-mode="hidden"] .request-location-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-workspace[data-location-mode="hidden"] .request-location-map,
|
||||||
|
.request-location-workspace[data-location-mode="hidden"] .request-location-controls,
|
||||||
|
.request-location-workspace[data-location-mode="hidden"] .request-location-manual {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-workspace[data-location-mode="hidden"] .request-location-hidden-state {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 63.999rem) {
|
||||||
|
.request-location-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-map {
|
||||||
|
min-height: 26rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 47.999rem) {
|
||||||
|
.request-location-workspace {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-modes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode {
|
||||||
|
min-height: 3.75rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-inline-start: 0;
|
||||||
|
border-top: 1px solid var(--color-base-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-mode:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-location-map {
|
||||||
|
min-height: 22rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,66 @@ const markerPoints = element => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptyFeatureCollection = () => ({type: "FeatureCollection", features: []})
|
||||||
|
|
||||||
|
const areaPolygon = ({longitude, latitude, radius_meters: radiusMeters}) => {
|
||||||
|
const earthRadiusMeters = 6371008.8
|
||||||
|
const angularDistance = radiusMeters / earthRadiusMeters
|
||||||
|
const latitudeRadians = latitude * Math.PI / 180
|
||||||
|
const longitudeRadians = longitude * Math.PI / 180
|
||||||
|
const coordinates = []
|
||||||
|
|
||||||
|
for (let step = 0; step <= 64; step += 1) {
|
||||||
|
const bearing = step / 64 * Math.PI * 2
|
||||||
|
const pointLatitude = Math.asin(
|
||||||
|
Math.sin(latitudeRadians) * Math.cos(angularDistance) +
|
||||||
|
Math.cos(latitudeRadians) * Math.sin(angularDistance) * Math.cos(bearing)
|
||||||
|
)
|
||||||
|
const pointLongitude = longitudeRadians + Math.atan2(
|
||||||
|
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitudeRadians),
|
||||||
|
Math.cos(angularDistance) - Math.sin(latitudeRadians) * Math.sin(pointLatitude)
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinates.push([pointLongitude * 180 / Math.PI, pointLatitude * 180 / Math.PI])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {type: "Polygon", coordinates: [coordinates]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const areaFeatureCollection = points => ({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: points
|
||||||
|
.filter(point => !point.exact && Number.isFinite(point.radius_meters))
|
||||||
|
.map(areaPolygon)
|
||||||
|
})
|
||||||
|
|
||||||
|
const extendBoundsWithFeature = (bounds, feature) => {
|
||||||
|
feature.geometry.coordinates[0].forEach(coordinate => bounds.extend(coordinate))
|
||||||
|
}
|
||||||
|
|
||||||
|
const privacySafeAreaCenter = (latitude, longitude, radiusMeters) => {
|
||||||
|
const latitudeStep = radiusMeters * Math.SQRT2 / 111320
|
||||||
|
const longitudeScale = Math.max(Math.cos(latitude * Math.PI / 180), 0.2)
|
||||||
|
const longitudeStep = latitudeStep / longitudeScale
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: Math.round(latitude / latitudeStep) * latitudeStep,
|
||||||
|
longitude: Math.round(longitude / longitudeStep) * longitudeStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createAidMap = element => {
|
const createAidMap = element => {
|
||||||
const state = {
|
const state = {
|
||||||
element,
|
element,
|
||||||
map: null,
|
map: null,
|
||||||
markers: []
|
markers: [],
|
||||||
|
areaSourceId: `${element.id || "aid-map"}-areas`,
|
||||||
|
areaFillId: `${element.id || "aid-map"}-area-fill`,
|
||||||
|
areaLineId: `${element.id || "aid-map"}-area-line`
|
||||||
}
|
}
|
||||||
|
|
||||||
state.markLoading = () => {
|
state.markLoading = () => {
|
||||||
|
|
@ -106,7 +161,36 @@ const createAidMap = element => {
|
||||||
state.markers = []
|
state.markers = []
|
||||||
|
|
||||||
const points = markerPoints(state.element)
|
const points = markerPoints(state.element)
|
||||||
points.forEach(point => {
|
const exactPoints = points.filter(point => point.exact || !Number.isFinite(point.radius_meters))
|
||||||
|
const areaFeatures = areaFeatureCollection(points)
|
||||||
|
|
||||||
|
if (state.map.isStyleLoaded()) {
|
||||||
|
const source = state.map.getSource(state.areaSourceId)
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
source.setData(areaFeatures)
|
||||||
|
} else {
|
||||||
|
state.map.addSource(state.areaSourceId, {type: "geojson", data: areaFeatures})
|
||||||
|
state.map.addLayer({
|
||||||
|
id: state.areaFillId,
|
||||||
|
type: "fill",
|
||||||
|
source: state.areaSourceId,
|
||||||
|
paint: {"fill-color": "#278467", "fill-opacity": 0.18}
|
||||||
|
})
|
||||||
|
state.map.addLayer({
|
||||||
|
id: state.areaLineId,
|
||||||
|
type: "line",
|
||||||
|
source: state.areaSourceId,
|
||||||
|
paint: {
|
||||||
|
"line-color": "#16725b",
|
||||||
|
"line-width": 2,
|
||||||
|
"line-dasharray": [2, 2]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exactPoints.forEach(point => {
|
||||||
const popupContent = document.createElement("div")
|
const popupContent = document.createElement("div")
|
||||||
const title = document.createElement("strong")
|
const title = document.createElement("strong")
|
||||||
title.textContent = point.title || ""
|
title.textContent = point.title || ""
|
||||||
|
|
@ -121,12 +205,11 @@ const createAidMap = element => {
|
||||||
state.markers.push(marker)
|
state.markers.push(marker)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (points.length === 1) {
|
if (points.length > 0) {
|
||||||
state.map.flyTo({center: [points[0].longitude, points[0].latitude], zoom: 12})
|
|
||||||
} else if (points.length > 1) {
|
|
||||||
const bounds = new maplibregl.LngLatBounds()
|
const bounds = new maplibregl.LngLatBounds()
|
||||||
points.forEach(point => bounds.extend([point.longitude, point.latitude]))
|
exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude]))
|
||||||
state.map.fitBounds(bounds, {padding: 52, maxZoom: 13})
|
areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature))
|
||||||
|
state.map.fitBounds(bounds, {padding: 52, maxZoom: 13, duration: 0})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,15 +244,20 @@ const createAidMap = element => {
|
||||||
new maplibregl.NavigationControl({showCompass: !demoMap}),
|
new maplibregl.NavigationControl({showCompass: !demoMap}),
|
||||||
demoMap ? "bottom-right" : "top-right"
|
demoMap ? "bottom-right" : "top-right"
|
||||||
)
|
)
|
||||||
|
state.onLoad = () => {
|
||||||
|
state.markReady()
|
||||||
|
state.renderMarkers()
|
||||||
|
}
|
||||||
|
|
||||||
state.map.on("dataloading", state.markLoading)
|
state.map.on("dataloading", state.markLoading)
|
||||||
state.map.on("load", state.markReady)
|
state.map.on("load", state.onLoad)
|
||||||
state.map.on("idle", state.markReady)
|
state.map.on("idle", state.markReady)
|
||||||
state.renderMarkers()
|
state.renderMarkers()
|
||||||
}
|
}
|
||||||
|
|
||||||
state.destroy = () => {
|
state.destroy = () => {
|
||||||
state.map?.off("dataloading", state.markLoading)
|
state.map?.off("dataloading", state.markLoading)
|
||||||
state.map?.off("load", state.markReady)
|
state.map?.off("load", state.onLoad)
|
||||||
state.map?.off("idle", state.markReady)
|
state.map?.off("idle", state.markReady)
|
||||||
state.map?.remove()
|
state.map?.remove()
|
||||||
}
|
}
|
||||||
|
|
@ -479,6 +567,320 @@ export const Hooks = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
RequestLocationPicker: {
|
||||||
|
mounted() {
|
||||||
|
this.map = null
|
||||||
|
this.marker = null
|
||||||
|
this.mapLoaded = false
|
||||||
|
this.areaSourceId = `${this.el.id}-selection-area`
|
||||||
|
this.areaFillId = `${this.el.id}-selection-fill`
|
||||||
|
this.areaLineId = `${this.el.id}-selection-line`
|
||||||
|
|
||||||
|
this.cacheElements = () => {
|
||||||
|
this.mapElement = this.el.querySelector(this.el.dataset.mapTarget)
|
||||||
|
this.latitudeInput = this.el.querySelector(this.el.dataset.latitudeTarget)
|
||||||
|
this.longitudeInput = this.el.querySelector(this.el.dataset.longitudeTarget)
|
||||||
|
this.statusElement = this.el.querySelector(this.el.dataset.statusTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mode = () =>
|
||||||
|
this.el.querySelector("[data-location-mode-input]:checked")?.value ||
|
||||||
|
this.el.dataset.locationMode ||
|
||||||
|
"approximate_public"
|
||||||
|
|
||||||
|
this.radius = () => {
|
||||||
|
const value = Number(
|
||||||
|
this.el.querySelector("[data-location-radius-input]:checked")?.value || 1000
|
||||||
|
)
|
||||||
|
return [500, 1000, 2000].includes(value) ? value : 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
this.coordinates = () => {
|
||||||
|
const latitudeValue = String(this.latitudeInput?.value || "").trim()
|
||||||
|
const longitudeValue = String(this.longitudeInput?.value || "").trim()
|
||||||
|
if (latitudeValue === "" || longitudeValue === "") return null
|
||||||
|
|
||||||
|
const latitude = Number(latitudeValue)
|
||||||
|
const longitude = Number(longitudeValue)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isFinite(latitude) ||
|
||||||
|
!Number.isFinite(longitude) ||
|
||||||
|
latitude < -90 ||
|
||||||
|
latitude > 90 ||
|
||||||
|
longitude < -180 ||
|
||||||
|
longitude > 180
|
||||||
|
) return null
|
||||||
|
|
||||||
|
return {latitude, longitude}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setStatus = message => {
|
||||||
|
if (this.statusElement) this.statusElement.textContent = message
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setSelectionStatus = () => {
|
||||||
|
if (!this.coordinates()) {
|
||||||
|
this.setStatus(this.el.dataset.mapReadyLabel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setStatus(
|
||||||
|
this.mode() === "approximate_public"
|
||||||
|
? this.el.dataset.areaSelectedLabel
|
||||||
|
: this.el.dataset.pointSelectedLabel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.focusSelection = () => {
|
||||||
|
const coordinates = this.coordinates()
|
||||||
|
if (!coordinates || !this.map) return
|
||||||
|
|
||||||
|
this.map.flyTo({
|
||||||
|
center: [coordinates.longitude, coordinates.latitude],
|
||||||
|
zoom: this.mode() === "exact_public" ? 15 : 13
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifyCoordinatesChanged = () => {
|
||||||
|
this.longitudeInput?.dispatchEvent(new Event("input", {bubbles: true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setCoordinates = ({latitude, longitude}, {notify = true, center = false} = {}) => {
|
||||||
|
if (!this.latitudeInput || !this.longitudeInput) return
|
||||||
|
|
||||||
|
this.latitudeInput.value = latitude.toFixed(6)
|
||||||
|
this.longitudeInput.value = longitude.toFixed(6)
|
||||||
|
this.renderSelection()
|
||||||
|
|
||||||
|
if (center && this.map) {
|
||||||
|
this.map.flyTo({center: [longitude, latitude], zoom: this.mode() === "exact_public" ? 15 : 13})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notify) this.notifyCoordinatesChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearCoordinates = ({notify = false} = {}) => {
|
||||||
|
if (this.latitudeInput) this.latitudeInput.value = ""
|
||||||
|
if (this.longitudeInput) this.longitudeInput.value = ""
|
||||||
|
this.renderSelection()
|
||||||
|
if (notify) this.notifyCoordinatesChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ensureAreaLayers = () => {
|
||||||
|
if (!this.mapLoaded || this.map.getSource(this.areaSourceId)) return
|
||||||
|
|
||||||
|
this.map.addSource(this.areaSourceId, {type: "geojson", data: emptyFeatureCollection()})
|
||||||
|
this.map.addLayer({
|
||||||
|
id: this.areaFillId,
|
||||||
|
type: "fill",
|
||||||
|
source: this.areaSourceId,
|
||||||
|
paint: {"fill-color": "#278467", "fill-opacity": 0.2}
|
||||||
|
})
|
||||||
|
this.map.addLayer({
|
||||||
|
id: this.areaLineId,
|
||||||
|
type: "line",
|
||||||
|
source: this.areaSourceId,
|
||||||
|
paint: {
|
||||||
|
"line-color": "#16725b",
|
||||||
|
"line-width": 2,
|
||||||
|
"line-dasharray": [2, 2]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setAreaData = data => {
|
||||||
|
if (!this.mapLoaded) return
|
||||||
|
this.ensureAreaLayers()
|
||||||
|
this.map.getSource(this.areaSourceId)?.setData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeMarker = () => {
|
||||||
|
this.marker?.remove()
|
||||||
|
this.marker = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderSelection = () => {
|
||||||
|
const mode = this.mode()
|
||||||
|
const coordinates = this.coordinates()
|
||||||
|
this.el.dataset.locationMode = mode
|
||||||
|
|
||||||
|
if (!this.mapLoaded) return
|
||||||
|
|
||||||
|
if (mode === "hidden") {
|
||||||
|
this.removeMarker()
|
||||||
|
this.setAreaData(emptyFeatureCollection())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coordinates) {
|
||||||
|
this.removeMarker()
|
||||||
|
this.setAreaData(emptyFeatureCollection())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "approximate_public") {
|
||||||
|
this.removeMarker()
|
||||||
|
this.setAreaData(areaFeatureCollection([{
|
||||||
|
...coordinates,
|
||||||
|
exact: false,
|
||||||
|
radius_meters: this.radius()
|
||||||
|
}]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setAreaData(emptyFeatureCollection())
|
||||||
|
|
||||||
|
if (!this.marker) {
|
||||||
|
this.marker = new maplibregl.Marker({color: "#d6573b", draggable: true})
|
||||||
|
.setLngLat([coordinates.longitude, coordinates.latitude])
|
||||||
|
.addTo(this.map)
|
||||||
|
|
||||||
|
this.marker.on("dragend", () => {
|
||||||
|
const point = this.marker.getLngLat()
|
||||||
|
this.setCoordinates({latitude: point.lat, longitude: point.lng})
|
||||||
|
this.setStatus(this.el.dataset.pointSelectedLabel)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.marker.setLngLat([coordinates.longitude, coordinates.latitude])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncMode = () => {
|
||||||
|
const mode = this.mode()
|
||||||
|
this.el.dataset.locationMode = mode
|
||||||
|
|
||||||
|
if (mode === "hidden") {
|
||||||
|
this.clearCoordinates()
|
||||||
|
this.setStatus(this.el.dataset.hiddenLabel)
|
||||||
|
} else {
|
||||||
|
this.renderSelection()
|
||||||
|
this.setSelectionStatus()
|
||||||
|
window.requestAnimationFrame(() => this.map?.resize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onChange = event => {
|
||||||
|
if (event.target.matches("[data-location-mode-input]")) {
|
||||||
|
this.syncMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.matches("[data-location-radius-input]")) {
|
||||||
|
this.renderSelection()
|
||||||
|
this.setSelectionStatus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.matches("[data-coordinate-input]")) {
|
||||||
|
this.renderSelection()
|
||||||
|
|
||||||
|
if (this.coordinates()) {
|
||||||
|
this.focusSelection()
|
||||||
|
this.setSelectionStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onClick = event => {
|
||||||
|
const button = event.target.closest("[data-use-current-location]")
|
||||||
|
if (!button) return
|
||||||
|
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
this.setStatus(this.el.dataset.locationUnavailableLabel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
button.disabled = true
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
position => {
|
||||||
|
const raw = {
|
||||||
|
latitude: position.coords.latitude,
|
||||||
|
longitude: position.coords.longitude
|
||||||
|
}
|
||||||
|
const selected = this.mode() === "approximate_public"
|
||||||
|
? privacySafeAreaCenter(raw.latitude, raw.longitude, this.radius())
|
||||||
|
: raw
|
||||||
|
|
||||||
|
this.setCoordinates(selected, {center: true})
|
||||||
|
this.setStatus(
|
||||||
|
this.mode() === "approximate_public"
|
||||||
|
? this.el.dataset.areaSelectedLabel
|
||||||
|
: this.el.dataset.pointSelectedLabel
|
||||||
|
)
|
||||||
|
button.disabled = false
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.setStatus(this.el.dataset.locationDeniedLabel)
|
||||||
|
button.disabled = false
|
||||||
|
},
|
||||||
|
{enableHighAccuracy: true, timeout: 10000}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cacheElements()
|
||||||
|
this.el.addEventListener("change", this.onChange)
|
||||||
|
this.el.addEventListener("click", this.onClick)
|
||||||
|
|
||||||
|
if (!this.mapElement || !supportsMapCanvas()) {
|
||||||
|
if (this.mapElement) {
|
||||||
|
this.mapElement.dataset.mapUnavailable = "true"
|
||||||
|
this.mapElement.textContent = this.mapElement.dataset.mapUnavailableLabel
|
||||||
|
}
|
||||||
|
this.syncMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialCoordinates = this.coordinates()
|
||||||
|
this.map = new maplibregl.Map({
|
||||||
|
container: this.mapElement,
|
||||||
|
style: defaultStyle,
|
||||||
|
center: initialCoordinates
|
||||||
|
? [initialCoordinates.longitude, initialCoordinates.latitude]
|
||||||
|
: [30.5234, 50.4501],
|
||||||
|
zoom: initialCoordinates ? 13 : 5,
|
||||||
|
attributionControl: true,
|
||||||
|
dragRotate: false,
|
||||||
|
pitchWithRotate: false
|
||||||
|
})
|
||||||
|
this.map.touchZoomRotate.disableRotation()
|
||||||
|
this.map.addControl(new maplibregl.NavigationControl({showCompass: false}), "top-left")
|
||||||
|
|
||||||
|
this.map.on("load", () => {
|
||||||
|
this.mapLoaded = true
|
||||||
|
this.mapElement.dataset.mapReady = "true"
|
||||||
|
this.ensureAreaLayers()
|
||||||
|
this.renderSelection()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.map.on("click", event => {
|
||||||
|
if (this.mode() === "hidden") return
|
||||||
|
|
||||||
|
this.setCoordinates(
|
||||||
|
{latitude: event.lngLat.lat, longitude: event.lngLat.lng},
|
||||||
|
{center: true}
|
||||||
|
)
|
||||||
|
this.setStatus(
|
||||||
|
this.mode() === "approximate_public"
|
||||||
|
? this.el.dataset.areaSelectedLabel
|
||||||
|
: this.el.dataset.pointSelectedLabel
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.syncMode()
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
this.cacheElements()
|
||||||
|
this.syncMode()
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
this.el.removeEventListener("change", this.onChange)
|
||||||
|
this.el.removeEventListener("click", this.onClick)
|
||||||
|
this.removeMarker()
|
||||||
|
this.map?.remove()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
LocationPicker: {
|
LocationPicker: {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.el.addEventListener("click", () => {
|
this.el.addEventListener("click", () => {
|
||||||
|
|
|
||||||
35
design-qa.md
Normal file
35
design-qa.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Request location picker — design QA
|
||||||
|
|
||||||
|
## Reference and implementation
|
||||||
|
|
||||||
|
- Target image: `/home/simple/.codex/generated_images/019f725d-87b5-79e1-8a9f-66e6eaffb35a/exec-d7ad900f-ed0f-4909-8a64-43bfc32dce7e.png`
|
||||||
|
- Desktop implementation: `/d/my_projects/who_need_help/output/playwright/location-picker-approximate-final.png`
|
||||||
|
- Mobile implementation: `/d/my_projects/who_need_help/output/playwright/location-picker-mobile-focused.png`
|
||||||
|
- Side-by-side comparison: `/d/my_projects/who_need_help/output/playwright/location-picker-comparison.png`
|
||||||
|
- Viewports checked: desktop `1673×1288`, mobile `390×844`
|
||||||
|
|
||||||
|
## Comparison
|
||||||
|
|
||||||
|
The implementation preserves the selected direction: a public landmark field, three clearly separated privacy modes, a large interactive map, radius choices for an approximate area, privacy guidance, and an optional manual-coordinate fallback. It adapts the reference into the existing Who Need Help visual system and request form rather than reproducing it as a disconnected screen.
|
||||||
|
|
||||||
|
The local end-to-end environment intentionally uses a deterministic solid raster tile. Map controls, marker/circle geometry, dragging, clicking, geolocation handling, and responsive layout are still exercised; the application continues to obtain its real raster tile URL from runtime configuration.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- P0: none.
|
||||||
|
- P1: none.
|
||||||
|
- P2: none.
|
||||||
|
- P3: the mobile screenshot shows only the location section because the full request form is vertically scrollable; no horizontal overflow was observed.
|
||||||
|
|
||||||
|
## Interaction checks
|
||||||
|
|
||||||
|
- Approximate mode stores and displays a bounded `500 m`, `1 km`, or `2 km` public area.
|
||||||
|
- Browser geolocation is privacy-snapped before it reaches the form in approximate mode.
|
||||||
|
- Exact mode displays a draggable marker and makes the public disclosure explicit.
|
||||||
|
- No-map mode clears coordinates and hides the map.
|
||||||
|
- A participant can later share live location privately after matching, independently of the public request location.
|
||||||
|
- The manual coordinate fallback remains open while both values are entered.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
Passed. No P0–P2 design discrepancy remains in the checked desktop and mobile states.
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
projectText,
|
projectText,
|
||||||
registerAndConfirm,
|
registerAndConfirm,
|
||||||
selectOptionContaining,
|
selectOptionContaining,
|
||||||
|
setRequestLocation,
|
||||||
waitForLiveViewConnected,
|
waitForLiveViewConnected,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
|
|
@ -53,13 +54,14 @@ test("active chat and browser tracking recover after the serving BEAM node resta
|
||||||
.getByLabel("What help do you need?")
|
.getByLabel("What help do you need?")
|
||||||
.fill("A run-scoped reserved medicine pickup for failover verification.");
|
.fill("A run-scoped reserved medicine pickup for failover verification.");
|
||||||
await requester.page.getByLabel("Urgency").selectOption("now");
|
await requester.page.getByLabel("Urgency").selectOption("now");
|
||||||
await requester.page
|
await requester.page.getByRole("button", { name: "In 3 hours" }).click();
|
||||||
.getByLabel("Request expires (UTC)")
|
await setRequestLocation(requester.page, {
|
||||||
.fill(new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString().slice(0, 16));
|
label: "Failover district",
|
||||||
await requester.page.getByLabel("Approximate area").fill("Failover district");
|
mode: "approximate_public",
|
||||||
await requester.page.getByLabel("Location visibility").selectOption("approximate_public");
|
latitude: "50.4501",
|
||||||
await requester.page.getByLabel("Latitude").fill("50.4501");
|
longitude: "30.5234",
|
||||||
await requester.page.getByLabel("Longitude").fill("30.5234");
|
radiusMeters: 1000,
|
||||||
|
});
|
||||||
await requester.page.locator("#request-form input[type=checkbox]").check();
|
await requester.page.locator("#request-form input[type=checkbox]").check();
|
||||||
await requester.page.getByRole("button", { name: "Publish request" }).click();
|
await requester.page.getByRole("button", { name: "Publish request" }).click();
|
||||||
await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/);
|
await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/);
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,49 @@ export async function gotoLiveView(page: Page, url: string): Promise<void> {
|
||||||
await waitForLiveViewConnected(page);
|
await waitForLiveViewConnected(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setRequestLocation(
|
||||||
|
page: Page,
|
||||||
|
options: {
|
||||||
|
label: string;
|
||||||
|
mode: "approximate_public" | "exact_public" | "hidden";
|
||||||
|
latitude?: string;
|
||||||
|
longitude?: string;
|
||||||
|
radiusMeters?: 500 | 1000 | 2000;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const workspace = page.locator("#request-location-workspace");
|
||||||
|
|
||||||
|
await workspace.getByLabel("Area or public landmark").fill(options.label);
|
||||||
|
await workspace
|
||||||
|
.locator(
|
||||||
|
`input[name="help_request[location_visibility]"][value="${options.mode}"]`,
|
||||||
|
)
|
||||||
|
.check();
|
||||||
|
|
||||||
|
if (options.mode === "hidden") {
|
||||||
|
await expect(workspace.locator("#request-latitude")).toHaveValue("");
|
||||||
|
await expect(workspace.locator("#request-longitude")).toHaveValue("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.mode === "approximate_public") {
|
||||||
|
await workspace
|
||||||
|
.locator(
|
||||||
|
`input[name="help_request[location_radius_meters]"][value="${options.radiusMeters ?? 1000}"]`,
|
||||||
|
)
|
||||||
|
.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
const manualCoordinates = workspace.locator("details.request-location-manual");
|
||||||
|
|
||||||
|
if (!(await manualCoordinates.getAttribute("open"))) {
|
||||||
|
await manualCoordinates.locator("summary").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await workspace.getByLabel("Latitude").fill(options.latitude ?? "50.4501");
|
||||||
|
await workspace.getByLabel("Longitude").fill(options.longitude ?? "30.5234");
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForMapReady(page: Page): Promise<void> {
|
export async function waitForMapReady(page: Page): Promise<void> {
|
||||||
const map = page.locator(".aid-map");
|
const map = page.locator(".aid-map");
|
||||||
|
|
||||||
|
|
@ -324,7 +367,7 @@ export async function registerAndConfirm(
|
||||||
await form
|
await form
|
||||||
.getByRole("button", { name: "Create account and email me a link" })
|
.getByRole("button", { name: "Create account and email me a link" })
|
||||||
.click();
|
.click();
|
||||||
await expect(page).toHaveURL(/\/users\/log-in$/);
|
await expect(page).toHaveURL(/\/users\/check-email\?flow=register$/);
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.getByRole("alert")
|
.getByRole("alert")
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
projectText,
|
projectText,
|
||||||
registerAndConfirm,
|
registerAndConfirm,
|
||||||
selectOptionContaining,
|
selectOptionContaining,
|
||||||
|
setRequestLocation,
|
||||||
waitForMapReady,
|
waitForMapReady,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
|
|
@ -43,14 +44,14 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
|
||||||
.getByLabel("What help do you need?")
|
.getByLabel("What help do you need?")
|
||||||
.fill("Please collect the legal medicine that is already reserved.");
|
.fill("Please collect the legal medicine that is already reserved.");
|
||||||
await requester.page.getByLabel("Urgency").selectOption("now");
|
await requester.page.getByLabel("Urgency").selectOption("now");
|
||||||
const expiresAt = new Date(Date.now() + 3 * 60 * 60 * 1000)
|
await requester.page.getByRole("button", { name: "In 3 hours" }).click();
|
||||||
.toISOString()
|
await setRequestLocation(requester.page, {
|
||||||
.slice(0, 16);
|
label: "E2E central district",
|
||||||
await requester.page.getByLabel("Request expires (UTC)").fill(expiresAt);
|
mode: "approximate_public",
|
||||||
await requester.page.getByLabel("Approximate area").fill("E2E central district");
|
latitude: "50.4501",
|
||||||
await requester.page.getByLabel("Location visibility").selectOption("approximate_public");
|
longitude: "30.5234",
|
||||||
await requester.page.getByLabel("Latitude").fill("50.4501");
|
radiusMeters: 1000,
|
||||||
await requester.page.getByLabel("Longitude").fill("30.5234");
|
});
|
||||||
await requester.page.locator("#request-form input[type=checkbox]").check();
|
await requester.page.locator("#request-form input[type=checkbox]").check();
|
||||||
await requester.page.getByRole("button", { name: "Publish request" }).click();
|
await requester.page.getByRole("button", { name: "Publish request" }).click();
|
||||||
await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/);
|
await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/);
|
||||||
|
|
@ -154,13 +155,11 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
|
||||||
.getByLabel("What help do you need?")
|
.getByLabel("What help do you need?")
|
||||||
.fill("The motorcycle is secured and needs help with a broken chain.");
|
.fill("The motorcycle is secured and needs help with a broken chain.");
|
||||||
await requester.page.getByLabel("Urgency").selectOption("today");
|
await requester.page.getByLabel("Urgency").selectOption("today");
|
||||||
await requester.page
|
await requester.page.getByRole("button", { name: "In 3 hours" }).click();
|
||||||
.getByLabel("Request expires (UTC)")
|
await setRequestLocation(requester.page, {
|
||||||
.fill(new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString().slice(0, 16));
|
label: "E2E secured roadside area",
|
||||||
await requester.page.getByLabel("Approximate area").fill("E2E secured roadside area");
|
mode: "hidden",
|
||||||
await requester.page.getByLabel("Location visibility").selectOption("hidden");
|
});
|
||||||
await requester.page.getByLabel("Latitude").fill("50.4501");
|
|
||||||
await requester.page.getByLabel("Longitude").fill("30.5234");
|
|
||||||
await requester.page.locator("#request-form input[type=checkbox]").last().check();
|
await requester.page.locator("#request-form input[type=checkbox]").last().check();
|
||||||
await requester.page.getByRole("button", { name: "Publish request" }).click();
|
await requester.page.getByRole("button", { name: "Publish request" }).click();
|
||||||
await expect(requester.page.getByRole("heading", { name: roadsideTitle })).toBeVisible();
|
await expect(requester.page.getByRole("heading", { name: roadsideTitle })).toBeVisible();
|
||||||
|
|
@ -173,7 +172,7 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
|
||||||
);
|
);
|
||||||
await clickUntilVisible(
|
await clickUntilVisible(
|
||||||
helper.page.getByRole("button", { name: "Withdraw from this request" }),
|
helper.page.getByRole("button", { name: "Withdraw from this request" }),
|
||||||
helper.page.getByText("You withdrew from this request."),
|
helper.page.getByText("This request was cancelled."),
|
||||||
);
|
);
|
||||||
await expect(requester.page.getByText("Cancelled", { exact: true })).toBeVisible();
|
await expect(requester.page.getByText("Cancelled", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
|
|
||||||
62
e2e/tests/request-location-picker.spec.ts
Normal file
62
e2e/tests/request-location-picker.spec.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
captureBrowserFailures,
|
||||||
|
gotoLiveView,
|
||||||
|
projectEmail,
|
||||||
|
registerAndConfirm,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
test("request location picker separates approximate, exact, and hidden disclosure", async ({
|
||||||
|
browser,
|
||||||
|
request,
|
||||||
|
}, testInfo) => {
|
||||||
|
const user = await registerAndConfirm(
|
||||||
|
browser,
|
||||||
|
request,
|
||||||
|
projectEmail("location-picker", testInfo.project.name),
|
||||||
|
"Location Picker",
|
||||||
|
);
|
||||||
|
const assertBrowserClean = captureBrowserFailures(user.page);
|
||||||
|
const origin = new URL(process.env.BASE_URL!).origin;
|
||||||
|
|
||||||
|
await user.context.grantPermissions(["geolocation"], { origin });
|
||||||
|
await user.context.setGeolocation({
|
||||||
|
latitude: 50.450123,
|
||||||
|
longitude: 30.523456,
|
||||||
|
accuracy: 5,
|
||||||
|
});
|
||||||
|
await gotoLiveView(user.page, "/requests/new");
|
||||||
|
|
||||||
|
const workspace = user.page.locator("#request-location-workspace");
|
||||||
|
const latitude = workspace.locator("#request-latitude");
|
||||||
|
const longitude = workspace.locator("#request-longitude");
|
||||||
|
|
||||||
|
await workspace.getByRole("button", { name: "Use my location" }).click();
|
||||||
|
await expect(workspace.locator("#request-location-status")).toHaveText(
|
||||||
|
"Approximate area selected. Your exact position is not stored.",
|
||||||
|
);
|
||||||
|
await expect(latitude).not.toHaveValue("50.450123");
|
||||||
|
await expect(longitude).not.toHaveValue("30.523456");
|
||||||
|
|
||||||
|
await workspace
|
||||||
|
.locator('input[name="help_request[location_visibility]"][value="exact_public"]')
|
||||||
|
.check();
|
||||||
|
await expect(workspace.getByText("This point is public")).toBeVisible();
|
||||||
|
await workspace.getByRole("button", { name: "Use my location" }).click();
|
||||||
|
await expect(workspace.locator("#request-location-status")).toHaveText(
|
||||||
|
"Exact public point selected.",
|
||||||
|
);
|
||||||
|
await expect(latitude).toHaveValue("50.450123");
|
||||||
|
await expect(longitude).toHaveValue("30.523456");
|
||||||
|
|
||||||
|
await workspace
|
||||||
|
.locator('input[name="help_request[location_visibility]"][value="hidden"]')
|
||||||
|
.check();
|
||||||
|
await expect(workspace.getByRole("heading", { name: "No coordinates will be stored" }))
|
||||||
|
.toBeVisible();
|
||||||
|
await expect(latitude).toHaveValue("");
|
||||||
|
await expect(longitude).toHaveValue("");
|
||||||
|
|
||||||
|
assertBrowserClean();
|
||||||
|
await user.context.close();
|
||||||
|
});
|
||||||
|
|
@ -508,13 +508,20 @@ defmodule WhoNeedHelp.Help do
|
||||||
|
|
||||||
if owner or
|
if owner or
|
||||||
(matched_participant and request.location_visibility == :exact_for_active_match) do
|
(matched_participant and request.location_visibility == :exact_for_active_match) do
|
||||||
%Geo.Point{coordinates: {lng, lat}} = request.location
|
exact_request_coordinates(request)
|
||||||
%{latitude: lat, longitude: lng, exact: true}
|
|
||||||
else
|
else
|
||||||
HelpRequest.public_coordinates(request)
|
HelpRequest.public_coordinates(request)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp exact_request_coordinates(%HelpRequest{
|
||||||
|
location: %Geo.Point{coordinates: {lng, lat}}
|
||||||
|
}) do
|
||||||
|
%{latitude: lat, longitude: lng, exact: true}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp exact_request_coordinates(%HelpRequest{location: nil}), do: nil
|
||||||
|
|
||||||
def handover_code(request_id) do
|
def handover_code(request_id) do
|
||||||
secret = Application.fetch_env!(:who_need_help, :handover_secret)
|
secret = Application.fetch_env!(:who_need_help, :handover_secret)
|
||||||
digest = :crypto.mac(:hmac, :sha256, secret, request_id)
|
digest = :crypto.mac(:hmac, :sha256, secret, request_id)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
||||||
field :structured_data, :map, default: %{}
|
field :structured_data, :map, default: %{}
|
||||||
field :location_label, :string
|
field :location_label, :string
|
||||||
field :location, Geo.PostGIS.Geometry
|
field :location, Geo.PostGIS.Geometry
|
||||||
|
field :location_radius_meters, :integer, default: 1_000
|
||||||
|
|
||||||
field :status, Ecto.Enum,
|
field :status, Ecto.Enum,
|
||||||
values: [:open, :matched, :in_progress, :completed, :cancelled, :expired],
|
values: [:open, :matched, :in_progress, :completed, :cancelled, :expired],
|
||||||
|
|
@ -44,6 +45,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
||||||
:pickup_instructions,
|
:pickup_instructions,
|
||||||
:structured_data,
|
:structured_data,
|
||||||
:location_label,
|
:location_label,
|
||||||
|
:location_radius_meters,
|
||||||
:urgency,
|
:urgency,
|
||||||
:location_visibility,
|
:location_visibility,
|
||||||
:expires_at,
|
:expires_at,
|
||||||
|
|
@ -55,7 +57,6 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
||||||
:title,
|
:title,
|
||||||
:description,
|
:description,
|
||||||
:location_label,
|
:location_label,
|
||||||
:location,
|
|
||||||
:urgency,
|
:urgency,
|
||||||
:location_visibility,
|
:location_visibility,
|
||||||
:expires_at,
|
:expires_at,
|
||||||
|
|
@ -65,6 +66,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
||||||
|> validate_length(:description, min: 10, max: 2_000)
|
|> validate_length(:description, min: 10, max: 2_000)
|
||||||
|> validate_length(:pickup_instructions, max: 1_000)
|
|> validate_length(:pickup_instructions, max: 1_000)
|
||||||
|> validate_length(:location_label, max: 255)
|
|> validate_length(:location_label, max: 255)
|
||||||
|
|> validate_location()
|
||||||
|> validate_acceptance(:safety_confirmed,
|
|> validate_acceptance(:safety_confirmed,
|
||||||
message: "confirm the safety guidance before publishing"
|
message: "confirm the safety guidance before publishing"
|
||||||
)
|
)
|
||||||
|
|
@ -78,6 +80,23 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp put_location(changeset, attrs) do
|
defp put_location(changeset, attrs) do
|
||||||
|
case get_field(changeset, :location_visibility) do
|
||||||
|
:hidden ->
|
||||||
|
changeset
|
||||||
|
|> put_change(:location, nil)
|
||||||
|
|> put_change(:location_radius_meters, nil)
|
||||||
|
|
||||||
|
:exact_public ->
|
||||||
|
changeset
|
||||||
|
|> put_location_point(attrs)
|
||||||
|
|> put_change(:location_radius_meters, nil)
|
||||||
|
|
||||||
|
_approximate_or_match_only ->
|
||||||
|
put_location_point(changeset, attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_location_point(changeset, attrs) do
|
||||||
lat = attrs["latitude"] || attrs[:latitude]
|
lat = attrs["latitude"] || attrs[:latitude]
|
||||||
lng = attrs["longitude"] || attrs[:longitude]
|
lng = attrs["longitude"] || attrs[:longitude]
|
||||||
|
|
||||||
|
|
@ -90,6 +109,22 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp validate_location(changeset) do
|
||||||
|
case get_field(changeset, :location_visibility) do
|
||||||
|
:hidden ->
|
||||||
|
changeset
|
||||||
|
|
||||||
|
visibility when visibility in [:approximate_public, :exact_for_active_match] ->
|
||||||
|
changeset
|
||||||
|
|> validate_required([:location])
|
||||||
|
|> validate_required([:location_radius_meters])
|
||||||
|
|> validate_inclusion(:location_radius_meters, allowed_location_radii())
|
||||||
|
|
||||||
|
_exact_or_invalid ->
|
||||||
|
validate_required(changeset, [:location])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp parse_coordinate(value) when is_float(value), do: {:ok, value}
|
defp parse_coordinate(value) when is_float(value), do: {:ok, value}
|
||||||
defp parse_coordinate(value) when is_integer(value), do: {:ok, value * 1.0}
|
defp parse_coordinate(value) when is_integer(value), do: {:ok, value * 1.0}
|
||||||
|
|
||||||
|
|
@ -110,11 +145,35 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def public_coordinates(%__MODULE__{location: %Geo.Point{coordinates: {lng, lat}}} = request) do
|
def allowed_location_radii, do: [500, 1_000, 2_000]
|
||||||
case request.location_visibility do
|
|
||||||
:hidden -> nil
|
def public_coordinates(%__MODULE__{location_visibility: :hidden}), do: nil
|
||||||
:exact_public -> %{latitude: lat, longitude: lng, exact: true}
|
def public_coordinates(%__MODULE__{location: nil}), do: nil
|
||||||
_ -> %{latitude: Float.round(lat, 2), longitude: Float.round(lng, 2), exact: false}
|
|
||||||
end
|
def public_coordinates(%__MODULE__{
|
||||||
|
location_visibility: :exact_public,
|
||||||
|
location: %Geo.Point{coordinates: {lng, lat}}
|
||||||
|
}) do
|
||||||
|
%{latitude: lat, longitude: lng, exact: true}
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_coordinates(%__MODULE__{
|
||||||
|
location_visibility: :approximate_public,
|
||||||
|
location: %Geo.Point{coordinates: {lng, lat}},
|
||||||
|
location_radius_meters: radius
|
||||||
|
}) do
|
||||||
|
%{latitude: lat, longitude: lng, exact: false, radius_meters: radius || 1_000}
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_coordinates(%__MODULE__{
|
||||||
|
location: %Geo.Point{coordinates: {lng, lat}},
|
||||||
|
location_radius_meters: radius
|
||||||
|
}) do
|
||||||
|
%{
|
||||||
|
latitude: Float.round(lat, 2),
|
||||||
|
longitude: Float.round(lng, 2),
|
||||||
|
exact: false,
|
||||||
|
radius_meters: radius || 1_000
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,16 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
expires = DateTime.utc_now(:second) |> DateTime.add(3, :hour)
|
expires = DateTime.utc_now(:second) |> DateTime.add(3, :hour)
|
||||||
default_location_visibility = socket.assigns.current_scope.user.location_visibility
|
|
||||||
|
default_location_visibility =
|
||||||
|
default_location_visibility(socket.assigns.current_scope.user.location_visibility)
|
||||||
|
|
||||||
changeset =
|
changeset =
|
||||||
Help.change_request(%HelpRequest{}, %{
|
Help.change_request(%HelpRequest{}, %{
|
||||||
"expires_at" => expires,
|
"expires_at" => expires,
|
||||||
"urgency" => "now",
|
"urgency" => "now",
|
||||||
"location_visibility" => to_string(default_location_visibility)
|
"location_visibility" => to_string(default_location_visibility),
|
||||||
|
"location_radius_meters" => "1000"
|
||||||
})
|
})
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
|
|
@ -92,7 +95,9 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
|> Enum.map(fn
|
|> Enum.map(fn
|
||||||
{:location, _error} ->
|
{:location, _error} ->
|
||||||
{:location,
|
{:location,
|
||||||
gettext("Choose a location using the button or enter both latitude and longitude.")}
|
gettext(
|
||||||
|
"Choose an area or exact point on the map, use your location, or select no map location."
|
||||||
|
)}
|
||||||
|
|
||||||
{:expires_at, _error} ->
|
{:expires_at, _error} ->
|
||||||
{:expires_at, gettext("Choose an expiry time in the future.")}
|
{:expires_at, gettext("Choose an expiry time in the future.")}
|
||||||
|
|
@ -109,6 +114,31 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
|> Calendar.strftime("%Y-%m-%dT%H:%M")
|
|> Calendar.strftime("%Y-%m-%dT%H:%M")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp default_location_visibility(visibility)
|
||||||
|
when visibility in [:exact_public, :hidden],
|
||||||
|
do: visibility
|
||||||
|
|
||||||
|
defp default_location_visibility(_visibility), do: :approximate_public
|
||||||
|
|
||||||
|
defp location_mode(form) do
|
||||||
|
case to_string(form[:location_visibility].value || "approximate_public") do
|
||||||
|
"exact_public" -> "exact_public"
|
||||||
|
"hidden" -> "hidden"
|
||||||
|
_approximate_or_legacy -> "approximate_public"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp location_radius(form) do
|
||||||
|
value = form[:location_radius_meters].value || 1_000
|
||||||
|
|
||||||
|
case to_string(value) do
|
||||||
|
radius when radius in ["500", "1000", "2000"] -> radius
|
||||||
|
_invalid_or_missing -> "1000"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp coordinate_value(form, key), do: Map.get(form.params, key, "")
|
||||||
|
|
||||||
defp assign_category(socket, params) do
|
defp assign_category(socket, params) do
|
||||||
category =
|
category =
|
||||||
Enum.find(socket.assigns.categories, &(&1.id == params["category_id"]))
|
Enum.find(socket.assigns.categories, &(&1.id == params["category_id"]))
|
||||||
|
|
@ -342,55 +372,217 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<.input
|
<section
|
||||||
field={@form[:location_label]}
|
id="request-location-workspace"
|
||||||
label={gettext("Approximate area")}
|
phx-hook="RequestLocationPicker"
|
||||||
placeholder={gettext("Podil, near the central pharmacy")}
|
data-location-mode={location_mode(@form)}
|
||||||
/>
|
data-map-target="#request-location-map"
|
||||||
<.input
|
|
||||||
field={@form[:location_visibility]}
|
|
||||||
type="select"
|
|
||||||
label={gettext("Location visibility")}
|
|
||||||
options={[
|
|
||||||
{gettext("Approximate publicly (recommended)"), "approximate_public"},
|
|
||||||
{gettext("Hidden"), "hidden"},
|
|
||||||
{gettext("Exact only for active match"), "exact_for_active_match"},
|
|
||||||
{gettext("Exact publicly — explicit consent"), "exact_public"}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div class="rounded-2xl bg-base-200 p-5">
|
|
||||||
<h2 class="font-bold">{gettext("Location coordinates (required)")}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-hook="LocationPicker"
|
|
||||||
id="location-picker"
|
|
||||||
data-latitude-target="#request-latitude"
|
data-latitude-target="#request-latitude"
|
||||||
data-longitude-target="#request-longitude"
|
data-longitude-target="#request-longitude"
|
||||||
class="btn btn-outline btn-sm mt-3"
|
data-status-target="#request-location-status"
|
||||||
|
data-map-ready-label={gettext("Choose a place on the map or use your current location.")}
|
||||||
|
data-area-selected-label={
|
||||||
|
gettext("Approximate area selected. Your exact position is not stored.")
|
||||||
|
}
|
||||||
|
data-point-selected-label={gettext("Exact public point selected.")}
|
||||||
|
data-hidden-label={gettext("No map coordinates will be stored.")}
|
||||||
|
data-location-unavailable-label={gettext("Location is unavailable in this browser.")}
|
||||||
|
data-location-denied-label={gettext("Location permission was denied.")}
|
||||||
|
class="request-location-workspace"
|
||||||
>
|
>
|
||||||
{gettext("Use my current foreground location")}
|
<div>
|
||||||
|
<h2 class="text-xl font-black">{gettext("Where is help needed?")}</h2>
|
||||||
|
<.input
|
||||||
|
field={@form[:location_label]}
|
||||||
|
label={gettext("Area or public landmark")}
|
||||||
|
placeholder={gettext("Podil, near the central pharmacy")}
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-sm text-base-content/60">
|
||||||
|
{gettext("This description is visible to everyone. Do not enter a private address.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="mt-5">
|
||||||
|
<legend class="mb-2 font-bold">{gettext("What should people see?")}</legend>
|
||||||
|
<div class="request-location-modes">
|
||||||
|
<label class="request-location-mode">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="help_request[location_visibility]"
|
||||||
|
value="approximate_public"
|
||||||
|
checked={location_mode(@form) == "approximate_public"}
|
||||||
|
data-location-mode-input
|
||||||
|
/>
|
||||||
|
<.icon name="hero-map" class="size-5" />
|
||||||
|
<span>
|
||||||
|
<strong>{gettext("Approximate area")}</strong>
|
||||||
|
<small>{gettext("Recommended for privacy")}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="request-location-mode">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="help_request[location_visibility]"
|
||||||
|
value="exact_public"
|
||||||
|
checked={location_mode(@form) == "exact_public"}
|
||||||
|
data-location-mode-input
|
||||||
|
/>
|
||||||
|
<.icon name="hero-map-pin" class="size-5" />
|
||||||
|
<span>
|
||||||
|
<strong>{gettext("Exact point")}</strong>
|
||||||
|
<small>{gettext("Visible before matching")}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="request-location-mode">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="help_request[location_visibility]"
|
||||||
|
value="hidden"
|
||||||
|
checked={location_mode(@form) == "hidden"}
|
||||||
|
data-location-mode-input
|
||||||
|
/>
|
||||||
|
<.icon name="hero-eye-slash" class="size-5" />
|
||||||
|
<span>
|
||||||
|
<strong>{gettext("No map location")}</strong>
|
||||||
|
<small>{gettext("Only the public area description")}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="request-location-grid mt-4">
|
||||||
|
<div class="request-location-map-shell">
|
||||||
|
<div
|
||||||
|
id="request-location-map"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-map-unavailable-label={
|
||||||
|
gettext("The map is unavailable. You can use the manual coordinate fallback.")
|
||||||
|
}
|
||||||
|
class="aid-map request-location-map"
|
||||||
|
/>
|
||||||
|
<div class="request-location-hidden-state">
|
||||||
|
<.icon name="hero-eye-slash" class="size-8" />
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold">{gettext("No coordinates will be stored")}</h3>
|
||||||
|
<p class="mt-1 text-sm text-base-content/60">
|
||||||
|
{gettext(
|
||||||
|
"Helpers will see only the area or landmark written above. You can share a live location later after matching."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="request-location-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-use-current-location
|
||||||
|
class="btn btn-outline btn-success w-full"
|
||||||
|
>
|
||||||
|
<.icon name="hero-paper-airplane" class="size-5" />
|
||||||
|
{gettext("Use my location")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<fieldset class="request-location-radius mt-4">
|
||||||
|
<legend class="mb-2 text-sm font-bold">{gettext("Area radius")}</legend>
|
||||||
|
<div class="grid grid-cols-3 gap-2 lg:grid-cols-1">
|
||||||
|
<label :for={
|
||||||
|
{label, value} <- [{"500 m", "500"}, {"1 km", "1000"}, {"2 km", "2000"}]
|
||||||
|
}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="help_request[location_radius_meters]"
|
||||||
|
value={value}
|
||||||
|
checked={location_radius(@form) == value}
|
||||||
|
data-location-radius-input
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="request-location-privacy mt-4" data-approximate-guidance>
|
||||||
|
<.icon name="hero-shield-check" class="size-6 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<strong>{gettext("Your exact position is not shown")}</strong>
|
||||||
|
<p>{gettext("Move the map area so it covers a useful public meeting place.")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="request-location-exact-warning mt-4" data-exact-guidance>
|
||||||
|
<.icon name="hero-eye" class="size-6 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<strong>{gettext("This point is public")}</strong>
|
||||||
|
<p>{gettext("Use a safe public meeting point instead of a private address.")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="request-location-after-match mt-4">
|
||||||
|
<.icon name="hero-lock-closed" class="size-6 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<strong>{gettext("After a helper accepts")}</strong>
|
||||||
|
<p>
|
||||||
|
{gettext(
|
||||||
|
"You can separately share live location with the matched person and stop sharing at any time."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
id="request-location-status"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
class="mt-3 min-h-5 text-sm font-medium text-base-content/65"
|
||||||
|
>
|
||||||
|
{gettext("Choose a place on the map or use your current location.")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details
|
||||||
|
id="request-location-manual"
|
||||||
|
phx-update="ignore"
|
||||||
|
class="request-location-manual mt-3"
|
||||||
|
>
|
||||||
|
<summary>{gettext("Enter coordinates manually")}</summary>
|
||||||
<p class="mt-2 text-xs text-base-content/60">
|
<p class="mt-2 text-xs text-base-content/60">
|
||||||
{gettext(
|
{gettext(
|
||||||
"Use the button or enter both coordinates. Public map precision follows the visibility choice above."
|
"For an approximate area, these coordinates are the area center—not your exact position."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||||
<.input
|
<label class="fieldset">
|
||||||
field={@form[:latitude]}
|
<span class="label">{gettext("Latitude")}</span>
|
||||||
|
<input
|
||||||
id="request-latitude"
|
id="request-latitude"
|
||||||
|
name="help_request[latitude]"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
label={gettext("Latitude")}
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
value={coordinate_value(@form, "latitude")}
|
||||||
|
class="input w-full"
|
||||||
|
data-coordinate-input
|
||||||
/>
|
/>
|
||||||
<.input
|
</label>
|
||||||
field={@form[:longitude]}
|
<label class="fieldset">
|
||||||
|
<span class="label">{gettext("Longitude")}</span>
|
||||||
|
<input
|
||||||
id="request-longitude"
|
id="request-longitude"
|
||||||
|
name="help_request[longitude]"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
label={gettext("Longitude")}
|
min="-180"
|
||||||
|
max="180"
|
||||||
|
value={coordinate_value(@form, "longitude")}
|
||||||
|
class="input w-full"
|
||||||
|
data-coordinate-input
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
:for={{:location, message} <- @submission_errors}
|
:for={{:location, message} <- @submission_errors}
|
||||||
id="request-location-error"
|
id="request-location-error"
|
||||||
|
|
@ -399,7 +591,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</section>
|
||||||
<div class="rounded-2xl border border-base-300 p-4 text-sm">
|
<div class="rounded-2xl border border-base-300 p-4 text-sm">
|
||||||
<.input
|
<.input
|
||||||
field={@form[:safety_confirmed]}
|
field={@form[:safety_confirmed]}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,70 @@
|
||||||
|
defmodule WhoNeedHelp.Repo.Migrations.AddRequestLocationArea do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:help_requests) do
|
||||||
|
add :location_radius_meters, :integer, default: 1_000
|
||||||
|
modify :location, :geometry, null: true
|
||||||
|
end
|
||||||
|
|
||||||
|
execute """
|
||||||
|
UPDATE help_requests
|
||||||
|
SET location = NULL,
|
||||||
|
location_radius_meters = NULL
|
||||||
|
WHERE location_visibility = 'hidden'
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute """
|
||||||
|
UPDATE help_requests
|
||||||
|
SET location_radius_meters = NULL
|
||||||
|
WHERE location_visibility = 'exact_public'
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute """
|
||||||
|
UPDATE help_requests
|
||||||
|
SET location = ST_SetSRID(
|
||||||
|
ST_MakePoint(
|
||||||
|
round(ST_X(location)::numeric, 2)::double precision,
|
||||||
|
round(ST_Y(location)::numeric, 2)::double precision
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
),
|
||||||
|
location_radius_meters = 1000
|
||||||
|
WHERE location_visibility = 'approximate_public'
|
||||||
|
AND location IS NOT NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
create constraint(:help_requests, :help_requests_location_radius_allowed,
|
||||||
|
check:
|
||||||
|
"location_radius_meters IS NULL OR location_radius_meters IN (500, 1000, 2000)"
|
||||||
|
)
|
||||||
|
|
||||||
|
create constraint(:help_requests, :help_requests_location_mode_valid,
|
||||||
|
check: """
|
||||||
|
(location_visibility = 'hidden' AND location IS NULL AND location_radius_meters IS NULL)
|
||||||
|
OR
|
||||||
|
(location_visibility = 'exact_public' AND location IS NOT NULL AND location_radius_meters IS NULL)
|
||||||
|
OR
|
||||||
|
(location_visibility IN ('approximate_public', 'exact_for_active_match')
|
||||||
|
AND location IS NOT NULL
|
||||||
|
AND location_radius_meters IN (500, 1000, 2000))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop constraint(:help_requests, :help_requests_location_mode_valid)
|
||||||
|
drop constraint(:help_requests, :help_requests_location_radius_allowed)
|
||||||
|
|
||||||
|
execute """
|
||||||
|
UPDATE help_requests
|
||||||
|
SET location = ST_SetSRID(ST_MakePoint(0, 0), 4326)
|
||||||
|
WHERE location IS NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
alter table(:help_requests) do
|
||||||
|
modify :location, :geometry, null: false
|
||||||
|
remove :location_radius_meters
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -23,6 +23,12 @@ mkdir -p "$output_dir"
|
||||||
export TRAEFIK_PROJECT_CONSTRAINT="$project"
|
export TRAEFIK_PROJECT_CONSTRAINT="$project"
|
||||||
export TRAEFIK_APP_NAME="who-need-help-e2e-$run_id"
|
export TRAEFIK_APP_NAME="who-need-help-e2e-$run_id"
|
||||||
export TRAEFIK_DOCKER_NETWORK="${project}_ingress"
|
export TRAEFIK_DOCKER_NETWORK="${project}_ingress"
|
||||||
|
# Compose interpolates required base-image variables before applying the E2E
|
||||||
|
# override. Keep those names local to the isolated runner; the application
|
||||||
|
# services themselves are still replaced with `who-need-help:e2e` below.
|
||||||
|
export APP_IMAGE="who-need-help:e2e"
|
||||||
|
export SOCKET_PROXY_IMAGE="who-need-help:socket-proxy-e2e"
|
||||||
|
export POSTGIS_IMAGE="who-need-help:postgis-e2e"
|
||||||
export E2E_OUTPUT_DIR="$output_dir"
|
export E2E_OUTPUT_DIR="$output_dir"
|
||||||
export E2E_UID
|
export E2E_UID
|
||||||
E2E_UID=$(id -u)
|
E2E_UID=$(id -u)
|
||||||
|
|
|
||||||
|
|
@ -405,9 +405,32 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
Help.create_request(context.helper_scope, context.attrs)
|
Help.create_request(context.helper_scope, context.attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "approximate requests expose only their bounded area geometry", context do
|
||||||
|
attrs = Map.put(context.attrs, "location_radius_meters", "500")
|
||||||
|
{:ok, request} = Help.create_request(context.requester_scope, attrs)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
latitude: 50.4501,
|
||||||
|
longitude: 30.5234,
|
||||||
|
exact: false,
|
||||||
|
radius_meters: 500
|
||||||
|
} = WhoNeedHelp.Help.HelpRequest.public_coordinates(request)
|
||||||
|
|
||||||
|
invalid_attrs = Map.put(context.attrs, "location_radius_meters", "5000")
|
||||||
|
assert {:error, changeset} = Help.create_request(context.requester_scope, invalid_attrs)
|
||||||
|
assert "is invalid" in errors_on(changeset).location_radius_meters
|
||||||
|
end
|
||||||
|
|
||||||
test "hidden request coordinates remain hidden from an accepted helper", context do
|
test "hidden request coordinates remain hidden from an accepted helper", context do
|
||||||
hidden_attrs = Map.put(context.attrs, "location_visibility", "hidden")
|
hidden_attrs =
|
||||||
|
context.attrs
|
||||||
|
|> Map.put("location_visibility", "hidden")
|
||||||
|
|> Map.drop(["latitude", "longitude"])
|
||||||
|
|
||||||
{:ok, request} = Help.create_request(context.requester_scope, hidden_attrs)
|
{:ok, request} = Help.create_request(context.requester_scope, hidden_attrs)
|
||||||
|
assert is_nil(request.location)
|
||||||
|
assert is_nil(request.location_radius_meters)
|
||||||
|
|
||||||
{:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)
|
{:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||||
request = Help.get_request!(request.id)
|
request = Help.get_request!(request.id)
|
||||||
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
|
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
|
||||||
|
|
@ -415,9 +438,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
assert is_nil(WhoNeedHelp.Help.HelpRequest.public_coordinates(request))
|
assert is_nil(WhoNeedHelp.Help.HelpRequest.public_coordinates(request))
|
||||||
assert is_nil(Help.request_coordinates(outsider_scope, request))
|
assert is_nil(Help.request_coordinates(outsider_scope, request))
|
||||||
assert is_nil(Help.request_coordinates(context.helper_scope, request))
|
assert is_nil(Help.request_coordinates(context.helper_scope, request))
|
||||||
|
assert is_nil(Help.request_coordinates(context.requester_scope, request))
|
||||||
assert %{latitude: 50.4501, longitude: 30.5234, exact: true} =
|
|
||||||
Help.request_coordinates(context.requester_scope, request)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "blocking an accepted helper revokes request, exact location, and tracking", context do
|
test "blocking an accepted helper revokes request, exact location, and tracking", context do
|
||||||
|
|
|
||||||
|
|
@ -316,7 +316,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
refute html =~ "controlled substance"
|
refute html =~ "controlled substance"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "new request form uses the profile location visibility default", %{
|
test "new request form normalizes legacy match-only visibility to a private area", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
user: user
|
user: user
|
||||||
} do
|
} do
|
||||||
|
|
@ -326,10 +326,40 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/requests/new")
|
{:ok, view, _html} = live(conn, ~p"/requests/new")
|
||||||
|
|
||||||
|
assert has_element?(view, "#request-location-workspace[phx-hook='RequestLocationPicker']")
|
||||||
|
|
||||||
assert has_element?(
|
assert has_element?(
|
||||||
view,
|
view,
|
||||||
"#help_request_location_visibility option[value='exact_for_active_match'][selected]"
|
"input[name='help_request[location_visibility]'][value='approximate_public'][checked]"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
refute has_element?(
|
||||||
|
view,
|
||||||
|
"input[name='help_request[location_visibility]'][value='exact_for_active_match']"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "new request form offers map privacy modes and bounded area radii", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/requests/new")
|
||||||
|
|
||||||
|
assert has_element?(view, "#request-location-map[phx-update='ignore']")
|
||||||
|
|
||||||
|
for mode <- ["approximate_public", "exact_public", "hidden"] do
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"input[name='help_request[location_visibility]'][value='#{mode}']"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
for radius <- ["500", "1000", "2000"] do
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"input[name='help_request[location_radius_meters]'][value='#{radius}']"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert has_element?(view, "#request-latitude[data-coordinate-input]")
|
||||||
|
assert has_element?(view, "#request-longitude[data-coordinate-input]")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "new request form uses separate local date and time controls", %{conn: conn} do
|
test "new request form uses separate local date and time controls", %{conn: conn} do
|
||||||
|
|
@ -365,7 +395,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
html = render_submit(view, "save", %{"help_request" => params})
|
html = render_submit(view, "save", %{"help_request" => params})
|
||||||
|
|
||||||
assert html =~ "Request was not published"
|
assert html =~ "Request was not published"
|
||||||
assert html =~ "Choose a location using the button or enter both latitude and longitude."
|
|
||||||
|
assert html =~
|
||||||
|
"Choose an area or exact point on the map, use your location, or select no map location."
|
||||||
|
|
||||||
assert html =~ "Choose an expiry time in the future."
|
assert html =~ "Choose an expiry time in the future."
|
||||||
assert has_element?(view, "#request-location-error")
|
assert has_element?(view, "#request-location-error")
|
||||||
assert has_element?(view, "#request-submit-errors[role='alert']")
|
assert has_element?(view, "#request-submit-errors[role='alert']")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user