Improve mobile discovery and request creation
This commit is contained in:
parent
ae663a745b
commit
13fbeec958
|
|
@ -577,6 +577,89 @@ html {
|
|||
box-shadow: 0 18px 48px rgb(23 37 31 / 0.10);
|
||||
}
|
||||
|
||||
.request-form-progress {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem;
|
||||
border: 1px solid var(--color-base-300);
|
||||
border-radius: 1rem;
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.request-form-progress a {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.request-form-progress a:hover,
|
||||
.request-form-progress a:focus-visible {
|
||||
background: var(--color-base-100);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.request-form-progress a > span {
|
||||
display: grid;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-base-300);
|
||||
color: var(--color-base-content);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.request-form-section {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--color-base-300);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--color-base-100);
|
||||
}
|
||||
|
||||
.request-form-section-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.request-form-section-heading > span {
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-success);
|
||||
color: var(--color-success-content);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.request-form-section-heading h2 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.request-form-section-heading p {
|
||||
margin-top: 0.2rem;
|
||||
color: color-mix(in oklab, var(--color-base-content) 62%, transparent);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.request-location-workspace {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--color-base-300);
|
||||
|
|
@ -845,6 +928,45 @@ html {
|
|||
}
|
||||
|
||||
@media (max-width: 47.999rem) {
|
||||
.request-form-progress {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 4.25rem;
|
||||
margin-inline: -0.25rem;
|
||||
box-shadow: 0 0.5rem 1.5rem rgb(23 37 31 / 0.12);
|
||||
}
|
||||
|
||||
.request-form-progress a {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.35rem 0.2rem;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.request-form-progress a > span {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.request-form-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.request-form-section-heading {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.request-form-publish {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||
box-shadow: 0 0.75rem 1.75rem rgb(23 37 31 / 0.22);
|
||||
}
|
||||
|
||||
.request-location-workspace {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -361,8 +361,12 @@ const createAidMap = element => {
|
|||
|
||||
if (point.id) {
|
||||
const link = document.createElement("a")
|
||||
link.href = `/requests/${encodeURIComponent(point.id)}`
|
||||
link.textContent = state.element.dataset.requestLabel || "Open request"
|
||||
const pathPrefix = state.element.dataset.itemPathPrefix || "/requests"
|
||||
link.href = `${pathPrefix}/${encodeURIComponent(point.id)}`
|
||||
link.textContent =
|
||||
state.element.dataset.itemLabel ||
|
||||
state.element.dataset.requestLabel ||
|
||||
"Open request"
|
||||
content.append(link)
|
||||
}
|
||||
|
||||
|
|
@ -524,7 +528,11 @@ const createAidMap = element => {
|
|||
element.className = "request-map-cluster"
|
||||
element.classList.toggle("is-urgent", Number(point.urgent_count) > 0)
|
||||
element.textContent = String(point.count)
|
||||
element.setAttribute("aria-label", `${point.count} requests`)
|
||||
element.setAttribute(
|
||||
"aria-label",
|
||||
(state.element.dataset.clusterLabel || "%{count} requests")
|
||||
.replace("%{count}", String(point.count))
|
||||
)
|
||||
element.addEventListener("click", event => {
|
||||
event.stopPropagation()
|
||||
state.map.easeTo({
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule WhoNeedHelp.Activities do
|
|||
alias WhoNeedHelp.Activities.{Activity, Message, Participant}
|
||||
alias WhoNeedHelp.Catalog
|
||||
alias WhoNeedHelp.Catalog.Category
|
||||
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||
alias WhoNeedHelp.Pagination
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.Trust
|
||||
|
|
@ -36,52 +37,118 @@ defmodule WhoNeedHelp.Activities do
|
|||
end
|
||||
|
||||
def paginate_open_activities(%Scope{user: user}, filters \\ %{}, options \\ []) do
|
||||
now = DateTime.utc_now(:second)
|
||||
limit = Pagination.limit(options)
|
||||
cursor = Pagination.cursor(options)
|
||||
public_creator = Accounts.public_user_query()
|
||||
viewport = Keyword.get(options, :viewport)
|
||||
|
||||
Activity
|
||||
|> where(
|
||||
[activity],
|
||||
activity.status == :open and activity.starts_at > ^now and
|
||||
activity.join_deadline > ^now and is_nil(activity.hidden_at)
|
||||
)
|
||||
|> where(
|
||||
[activity],
|
||||
activity.creator_id not in subquery(
|
||||
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
|
||||
)
|
||||
)
|
||||
|> where(
|
||||
[activity],
|
||||
activity.creator_id not in subquery(
|
||||
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
|
||||
)
|
||||
)
|
||||
|> maybe_filter_category(filters["category_id"] || filters[:category_id])
|
||||
user
|
||||
|> open_activities_query(filters, viewport)
|
||||
|> after_open_activity(cursor)
|
||||
|> order_by([activity], asc: activity.starts_at, asc: activity.id)
|
||||
|> limit(^(limit + 1))
|
||||
|> with_approved_participant_count()
|
||||
|> preload([activity], category: :parent, creator: ^public_creator)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(category: :parent, creator: Accounts.public_user_query())
|
||||
|> Pagination.page(limit, &{&1.starts_at, &1.id})
|
||||
end
|
||||
|
||||
def map_discovery_items(%Scope{user: user}, filters, %DiscoveryViewport{} = viewport) do
|
||||
cell_size = DiscoveryViewport.cluster_cell_meters(viewport)
|
||||
|
||||
points =
|
||||
user
|
||||
|> open_activities_query(filters, viewport)
|
||||
|> where([activity], activity.location_visibility == :approximate_public)
|
||||
|> select([activity], %{
|
||||
id: activity.id,
|
||||
title: activity.title,
|
||||
location_label: activity.location_label,
|
||||
public_location:
|
||||
fragment(
|
||||
"ST_SetSRID(ST_MakePoint(round(ST_X(?)::numeric, 2)::double precision, round(ST_Y(?)::numeric, 2)::double precision), 4326)",
|
||||
activity.location,
|
||||
activity.location
|
||||
)
|
||||
})
|
||||
|
||||
points
|
||||
|> subquery()
|
||||
|> group_by(
|
||||
[point],
|
||||
fragment(
|
||||
"ST_SnapToGrid(ST_Transform(?, 3857), ?)",
|
||||
point.public_location,
|
||||
constant(^cell_size)
|
||||
)
|
||||
)
|
||||
|> select([point], %{
|
||||
count: count(point.id),
|
||||
activity_id: fragment("CASE WHEN count(*) = 1 THEN min(?::text) END", point.id),
|
||||
title: fragment("CASE WHEN count(*) = 1 THEN min(?) END", point.title),
|
||||
location_label: fragment("CASE WHEN count(*) = 1 THEN min(?) END", point.location_label),
|
||||
longitude:
|
||||
fragment(
|
||||
"ST_X(ST_Transform(ST_Centroid(ST_Collect(ST_Transform(?, 3857))), 4326))",
|
||||
point.public_location
|
||||
),
|
||||
latitude:
|
||||
fragment(
|
||||
"ST_Y(ST_Transform(ST_Centroid(ST_Collect(ST_Transform(?, 3857))), 4326))",
|
||||
point.public_location
|
||||
),
|
||||
cell_x:
|
||||
fragment(
|
||||
"ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
|
||||
point.public_location,
|
||||
constant(^cell_size)
|
||||
),
|
||||
cell_y:
|
||||
fragment(
|
||||
"ST_Y(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
|
||||
point.public_location,
|
||||
constant(^cell_size)
|
||||
)
|
||||
})
|
||||
|> order_by(
|
||||
[point],
|
||||
asc:
|
||||
fragment(
|
||||
"ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
|
||||
point.public_location,
|
||||
constant(^cell_size)
|
||||
),
|
||||
asc:
|
||||
fragment(
|
||||
"ST_Y(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
|
||||
point.public_location,
|
||||
constant(^cell_size)
|
||||
)
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.map(&map_discovery_item(&1, viewport.zoom))
|
||||
end
|
||||
|
||||
def list_my_activities(%Scope{user: user}) do
|
||||
paginate_my_activities(%Scope{user: user}).entries
|
||||
end
|
||||
|
||||
def visible_open_activity?(%Scope{user: user}, %Activity{} = activity, filters \\ %{}) do
|
||||
def visible_open_activity?(
|
||||
%Scope{user: user},
|
||||
%Activity{} = activity,
|
||||
filters \\ %{},
|
||||
viewport \\ nil
|
||||
) do
|
||||
now = DateTime.utc_now(:second)
|
||||
category_id = filters["category_id"] || filters[:category_id]
|
||||
area = filters["area"] || filters[:area]
|
||||
|
||||
activity.status == :open and is_nil(activity.hidden_at) and
|
||||
DateTime.after?(activity.starts_at, now) and
|
||||
DateTime.after?(activity.join_deadline, now) and
|
||||
(category_id in [nil, ""] or
|
||||
to_string(activity.category_id) == to_string(category_id)) and
|
||||
area_matches?(activity, area) and
|
||||
viewport_matches?(activity, viewport, area) and
|
||||
not Trust.blocked_between?(user.id, activity.creator_id)
|
||||
end
|
||||
|
||||
|
|
@ -721,6 +788,159 @@ defmodule WhoNeedHelp.Activities do
|
|||
})
|
||||
end
|
||||
|
||||
defp open_activities_query(user, filters, viewport) do
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
Activity
|
||||
|> where(
|
||||
[activity],
|
||||
activity.status == :open and activity.starts_at > ^now and
|
||||
activity.join_deadline > ^now and is_nil(activity.hidden_at)
|
||||
)
|
||||
|> where(
|
||||
[activity],
|
||||
activity.creator_id not in subquery(
|
||||
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
|
||||
)
|
||||
)
|
||||
|> where(
|
||||
[activity],
|
||||
activity.creator_id not in subquery(
|
||||
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
|
||||
)
|
||||
)
|
||||
|> maybe_filter_category(filters["category_id"] || filters[:category_id])
|
||||
|> maybe_filter_area(filters["area"] || filters[:area])
|
||||
|> filter_discovery_viewport(viewport, filters)
|
||||
end
|
||||
|
||||
defp filter_discovery_viewport(query, nil, _filters), do: query
|
||||
|
||||
defp filter_discovery_viewport(query, %DiscoveryViewport{} = viewport, filters) do
|
||||
spatial_condition =
|
||||
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
||||
{west, south, east, north}, condition ->
|
||||
dynamic(
|
||||
[activity],
|
||||
^condition or
|
||||
fragment(
|
||||
"ST_Intersects(?, ST_MakeEnvelope(?, ?, ?, ?, 4326))",
|
||||
activity.location,
|
||||
^west,
|
||||
^south,
|
||||
^east,
|
||||
^north
|
||||
)
|
||||
)
|
||||
end)
|
||||
|
||||
area = filters["area"] || filters[:area]
|
||||
|
||||
condition =
|
||||
if is_binary(area) and String.trim(area) != "" do
|
||||
dynamic(
|
||||
[activity],
|
||||
(activity.location_visibility == :approximate_public and ^spatial_condition) or
|
||||
activity.location_visibility == :hidden
|
||||
)
|
||||
else
|
||||
dynamic(
|
||||
[activity],
|
||||
activity.location_visibility == :approximate_public and ^spatial_condition
|
||||
)
|
||||
end
|
||||
|
||||
where(query, ^condition)
|
||||
end
|
||||
|
||||
defp maybe_filter_area(query, value) when value in [nil, ""], do: query
|
||||
|
||||
defp maybe_filter_area(query, value) when is_binary(value) do
|
||||
value = value |> String.trim() |> String.slice(0, 100)
|
||||
|
||||
if value == "" do
|
||||
query
|
||||
else
|
||||
pattern = "%#{escape_like(value)}%"
|
||||
|
||||
where(
|
||||
query,
|
||||
[activity],
|
||||
fragment("? ILIKE ? ESCAPE E'\\\\'", activity.location_label, ^pattern)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_filter_area(query, _value), do: where(query, [activity], false)
|
||||
|
||||
defp area_matches?(_activity, value) when value in [nil, ""], do: true
|
||||
|
||||
defp area_matches?(activity, value) when is_binary(value) do
|
||||
normalized = value |> String.trim() |> String.downcase()
|
||||
|
||||
normalized == "" or
|
||||
String.contains?(String.downcase(activity.location_label || ""), normalized)
|
||||
end
|
||||
|
||||
defp area_matches?(_activity, _value), do: false
|
||||
|
||||
defp viewport_matches?(_activity, nil, _area), do: true
|
||||
|
||||
defp viewport_matches?(
|
||||
%Activity{location_visibility: :hidden},
|
||||
%DiscoveryViewport{},
|
||||
area
|
||||
),
|
||||
do: is_binary(area) and String.trim(area) != ""
|
||||
|
||||
defp viewport_matches?(activity, %DiscoveryViewport{} = viewport, _area) do
|
||||
%{latitude: latitude, longitude: longitude} = Activity.exact_coordinates(activity)
|
||||
|
||||
latitude >= viewport.south and latitude <= viewport.north and
|
||||
longitude_in_viewport?(longitude, viewport)
|
||||
end
|
||||
|
||||
defp longitude_in_viewport?(longitude, %DiscoveryViewport{west: west, east: east})
|
||||
when west < east,
|
||||
do: longitude >= west and longitude <= east
|
||||
|
||||
defp longitude_in_viewport?(longitude, %DiscoveryViewport{west: west, east: east}),
|
||||
do: longitude >= west or longitude <= east
|
||||
|
||||
defp map_discovery_item(%{count: 1} = row, _zoom) do
|
||||
%{
|
||||
type: "activity",
|
||||
id: row.activity_id,
|
||||
title: row.title,
|
||||
location: row.location_label,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
exact: false
|
||||
}
|
||||
end
|
||||
|
||||
defp map_discovery_item(row, zoom) do
|
||||
digest =
|
||||
:crypto.hash(:sha256, "activity:#{zoom}:#{row.cell_x}:#{row.cell_y}")
|
||||
|> Base.url_encode64(padding: false)
|
||||
|> binary_part(0, 16)
|
||||
|
||||
%{
|
||||
type: "cluster",
|
||||
id: "activity-cluster-#{digest}",
|
||||
count: row.count,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude
|
||||
}
|
||||
end
|
||||
|
||||
defp escape_like(value) do
|
||||
value
|
||||
|> String.replace("\\", "\\\\")
|
||||
|> String.replace("%", "\\%")
|
||||
|> String.replace("_", "\\_")
|
||||
end
|
||||
|
||||
defp locked_activity(id) do
|
||||
Activity
|
||||
|> where([activity], activity.id == ^id)
|
||||
|
|
|
|||
|
|
@ -169,7 +169,15 @@ defmodule WhoNeedHelp.Help do
|
|||
cursor = Pagination.cursor(options)
|
||||
|
||||
HelpRequest
|
||||
|> where([r], r.requester_id == ^user.id)
|
||||
|> where(
|
||||
[request],
|
||||
request.requester_id == ^user.id or
|
||||
request.id in subquery(
|
||||
from assignment in Assignment,
|
||||
where: assignment.helper_id == ^user.id,
|
||||
select: assignment.request_id
|
||||
)
|
||||
)
|
||||
|> before_my_request(cursor)
|
||||
|> order_by([request], desc: request.inserted_at, desc: request.id)
|
||||
|> limit(^(limit + 1))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
use WhoNeedHelpWeb, :live_view
|
||||
|
||||
alias WhoNeedHelp.{Activities, Catalog, Pagination}
|
||||
alias WhoNeedHelp.Activities.Activity
|
||||
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||
|
||||
@discovery_modes ~w(nearby mine)
|
||||
@filter_keys ~w(category_id area)
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
@ -13,7 +16,33 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:filters, %{"category_id" => ""})
|
||||
|> assign(:filters, empty_filters())
|
||||
|> assign(:discovery_mode, "nearby")
|
||||
|> assign(:viewport, nil)
|
||||
|> assign(:pending_viewport, nil)
|
||||
|> assign(:activities, [])
|
||||
|> assign(:activities_cursor, nil)
|
||||
|> assign(:map_items, Jason.encode!([]))
|
||||
|> assign(:map_result_count, 0)
|
||||
|> assign(:my_activities, [])
|
||||
|> assign(:my_activities_cursor, nil)
|
||||
|> assign(:page_title, gettext("Activities"))
|
||||
|> assign(:categories, Catalog.list_categories(:activity))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
filters = normalize_filters(params)
|
||||
viewport = parse_viewport(params)
|
||||
discovery_mode = normalize_discovery_mode(params["scope"])
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:filters, filters)
|
||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||
|> assign(:discovery_mode, discovery_mode)
|
||||
|> assign(:viewport, viewport)
|
||||
|> assign(:pending_viewport, nil)
|
||||
|> load()}
|
||||
end
|
||||
|
||||
|
|
@ -27,10 +56,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
update_entry(
|
||||
socket.assigns.activities,
|
||||
activity,
|
||||
not is_nil(socket.assigns.viewport) and
|
||||
Activities.visible_open_activity?(
|
||||
socket.assigns.current_scope,
|
||||
activity,
|
||||
socket.assigns.filters
|
||||
socket.assigns.filters,
|
||||
socket.assigns.viewport
|
||||
),
|
||||
:asc,
|
||||
socket.assigns.activities_cursor
|
||||
|
|
@ -51,7 +82,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
|> assign(:activities_cursor, activities_cursor)
|
||||
|> assign(:my_activities, my_activities)
|
||||
|> assign(:my_activities_cursor, my_activities_cursor)
|
||||
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))}
|
||||
|> refresh_map_items()}
|
||||
end
|
||||
|
||||
def handle_info({:my_activity_updated, activity, member?}, socket) do
|
||||
|
|
@ -72,7 +103,47 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("filter", %{"filters" => filters}, socket) do
|
||||
{:noreply, socket |> assign(:filters, filters) |> load()}
|
||||
{:noreply, push_patch(socket, to: activity_path(socket, filters: normalize_filters(filters)))}
|
||||
end
|
||||
|
||||
def handle_event("set-scope", %{"scope" => discovery_mode}, socket)
|
||||
when discovery_mode in @discovery_modes do
|
||||
{:noreply, push_patch(socket, to: activity_path(socket, discovery_mode: discovery_mode))}
|
||||
end
|
||||
|
||||
def handle_event("map-viewport-change", params, socket) do
|
||||
case DiscoveryViewport.cast(params) do
|
||||
{:ok, viewport} ->
|
||||
pending =
|
||||
if DiscoveryViewport.same_area?(viewport, socket.assigns.viewport),
|
||||
do: nil,
|
||||
else: viewport
|
||||
|
||||
{:noreply, assign(socket, :pending_viewport, pending)}
|
||||
|
||||
:error ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("initialize-map-area", params, socket) do
|
||||
case {socket.assigns.viewport, DiscoveryViewport.cast(params)} do
|
||||
{nil, {:ok, viewport}} ->
|
||||
{:noreply, push_patch(socket, to: activity_path(socket, viewport: viewport))}
|
||||
|
||||
_already_initialized_or_invalid ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("search-map-area", _params, socket) do
|
||||
case socket.assigns.pending_viewport do
|
||||
%DiscoveryViewport{} = viewport ->
|
||||
{:noreply, push_patch(socket, to: activity_path(socket, viewport: viewport))}
|
||||
|
||||
_no_pending_viewport ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("load-more-activities", _params, socket) do
|
||||
|
|
@ -80,7 +151,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
Activities.paginate_open_activities(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.filters,
|
||||
after: socket.assigns.activities_cursor
|
||||
after: socket.assigns.activities_cursor,
|
||||
viewport: socket.assigns.viewport
|
||||
)
|
||||
|
||||
activities = append_unique(socket.assigns.activities, page.entries)
|
||||
|
|
@ -89,7 +161,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
socket
|
||||
|> assign(:activities, activities)
|
||||
|> assign(:activities_cursor, page.next_cursor)
|
||||
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))}
|
||||
|> refresh_map_items()}
|
||||
end
|
||||
|
||||
def handle_event("load-more-my-activities", _params, socket) do
|
||||
|
|
@ -105,24 +177,46 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
end
|
||||
|
||||
defp load(socket) do
|
||||
activities_page =
|
||||
{activities_page, map_items} =
|
||||
case {socket.assigns.discovery_mode, socket.assigns.viewport} do
|
||||
{"nearby", %DiscoveryViewport{} = viewport} ->
|
||||
page =
|
||||
Activities.paginate_open_activities(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.filters
|
||||
socket.assigns.filters,
|
||||
viewport: viewport
|
||||
)
|
||||
|
||||
my_activities_page = Activities.paginate_my_activities(socket.assigns.current_scope)
|
||||
items =
|
||||
Activities.map_discovery_items(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.filters,
|
||||
viewport
|
||||
)
|
||||
|
||||
{page, items}
|
||||
|
||||
_mine_or_missing_viewport ->
|
||||
{%Pagination.Page{}, []}
|
||||
end
|
||||
|
||||
my_activities_page =
|
||||
if socket.assigns.discovery_mode == "mine" do
|
||||
Activities.paginate_my_activities(socket.assigns.current_scope)
|
||||
else
|
||||
%Pagination.Page{}
|
||||
end
|
||||
|
||||
activities = activities_page.entries
|
||||
|
||||
socket
|
||||
|> assign(:page_title, gettext("Activities"))
|
||||
|> assign(:activities, activities)
|
||||
|> assign(:activities_cursor, activities_page.next_cursor)
|
||||
|> assign(:my_activities, my_activities_page.entries)
|
||||
|> assign(:my_activities_cursor, my_activities_page.next_cursor)
|
||||
|> assign(:categories, Catalog.list_categories(:activity))
|
||||
|> assign(:filter_form, to_form(socket.assigns.filters, as: :filters))
|
||||
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))
|
||||
|> assign(:map_items, Jason.encode!(map_items))
|
||||
|> assign(:map_result_count, Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)))
|
||||
end
|
||||
|
||||
defp append_unique(existing, incoming) do
|
||||
|
|
@ -149,18 +243,72 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
{entries, cursor}
|
||||
end
|
||||
|
||||
defp marker(activity) do
|
||||
case Activity.public_coordinates(activity) do
|
||||
nil ->
|
||||
nil
|
||||
defp refresh_map_items(%{assigns: %{viewport: %DiscoveryViewport{} = viewport}} = socket) do
|
||||
map_items =
|
||||
Activities.map_discovery_items(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.filters,
|
||||
viewport
|
||||
)
|
||||
|
||||
coordinates ->
|
||||
Map.merge(coordinates, %{
|
||||
id: activity.id,
|
||||
title: activity.title,
|
||||
location: activity.location_label
|
||||
})
|
||||
socket
|
||||
|> assign(:map_items, Jason.encode!(map_items))
|
||||
|> assign(:map_result_count, Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)))
|
||||
end
|
||||
|
||||
defp refresh_map_items(socket),
|
||||
do: socket |> assign(:map_items, Jason.encode!([])) |> assign(:map_result_count, 0)
|
||||
|
||||
defp map_item_count(%{type: "cluster", count: count}), do: count
|
||||
defp map_item_count(_activity), do: 1
|
||||
|
||||
defp empty_filters, do: %{"category_id" => "", "area" => ""}
|
||||
|
||||
defp normalize_filters(params) do
|
||||
Map.new(@filter_keys, fn key ->
|
||||
{key, params[key] |> to_string() |> String.slice(0, 100)}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_viewport(params) do
|
||||
case DiscoveryViewport.cast(params) do
|
||||
{:ok, viewport} -> viewport
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_discovery_mode(discovery_mode) when discovery_mode in @discovery_modes,
|
||||
do: discovery_mode
|
||||
|
||||
defp normalize_discovery_mode(_discovery_mode), do: "nearby"
|
||||
|
||||
defp activity_path(socket, overrides) do
|
||||
filters = Keyword.get(overrides, :filters, socket.assigns.filters)
|
||||
viewport = Keyword.get(overrides, :viewport, socket.assigns.viewport)
|
||||
discovery_mode = Keyword.get(overrides, :discovery_mode, socket.assigns.discovery_mode)
|
||||
|
||||
params =
|
||||
filters
|
||||
|> Map.reject(fn {_key, value} -> value == "" end)
|
||||
|> then(fn params ->
|
||||
if discovery_mode == "mine", do: Map.put(params, "scope", "mine"), else: params
|
||||
end)
|
||||
|> Map.merge(
|
||||
if(discovery_mode == "nearby" && viewport,
|
||||
do: DiscoveryViewport.query_params(viewport),
|
||||
else: %{}
|
||||
)
|
||||
)
|
||||
|
||||
~p"/activities?#{params}"
|
||||
end
|
||||
|
||||
defp viewport_json(nil), do: ""
|
||||
|
||||
defp viewport_json(%DiscoveryViewport{} = viewport) do
|
||||
viewport
|
||||
|> DiscoveryViewport.query_params()
|
||||
|> Jason.encode!()
|
||||
end
|
||||
|
||||
defp status_label(:open), do: gettext("Open")
|
||||
|
|
@ -198,11 +346,38 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
class="mt-5 grid grid-cols-2 gap-1 rounded-2xl border border-base-300 bg-base-200 p-1"
|
||||
aria-label={gettext("Activity views")}
|
||||
>
|
||||
<button
|
||||
:for={
|
||||
{scope, label, icon} <- [
|
||||
{"nearby", gettext("Nearby activities"), "hero-map-pin"},
|
||||
{"mine", gettext("My activities"), "hero-user-group"}
|
||||
]
|
||||
}
|
||||
type="button"
|
||||
phx-click="set-scope"
|
||||
phx-value-scope={scope}
|
||||
aria-pressed={to_string(@discovery_mode == scope)}
|
||||
class={[
|
||||
"btn min-h-11 min-w-0 border-0 px-2 sm:px-4",
|
||||
@discovery_mode == scope && "bg-base-100 text-info shadow-sm",
|
||||
@discovery_mode != scope && "btn-ghost text-base-content/65"
|
||||
]}
|
||||
>
|
||||
<.icon name={icon} class="size-5 shrink-0" />
|
||||
<span class="truncate">{label}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<.form
|
||||
:if={@discovery_mode == "nearby"}
|
||||
for={@filter_form}
|
||||
id="activity-filters"
|
||||
phx-change="filter"
|
||||
class="mt-5 rounded-2xl bg-base-200 p-4"
|
||||
class="mt-5 grid gap-3 rounded-2xl bg-base-200 p-4 md:grid-cols-2"
|
||||
>
|
||||
<.input
|
||||
field={@filter_form[:category_id]}
|
||||
|
|
@ -216,17 +391,37 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
)
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@filter_form[:area]}
|
||||
label={gettext("Area or city")}
|
||||
placeholder={gettext("Optional text search")}
|
||||
phx-debounce="400"
|
||||
/>
|
||||
</.form>
|
||||
|
||||
<div class="mt-7 grid gap-7 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,.8fr)]">
|
||||
<div
|
||||
:if={@discovery_mode == "nearby"}
|
||||
class="mt-7 grid gap-7 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,.8fr)]"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
:if={@activities == []}
|
||||
:if={is_nil(@viewport)}
|
||||
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
||||
>
|
||||
<.icon name="hero-map" class="mx-auto size-8 text-info" />
|
||||
<h2 class="mt-3 text-xl font-bold">{gettext("Choose an area on the map")}</h2>
|
||||
<p class="mt-2 text-base-content/60">
|
||||
{gettext("Only activities from the selected map area will be loaded.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@viewport && @activities == []}
|
||||
class="rounded-3xl border border-dashed border-base-300 p-12 text-center"
|
||||
>
|
||||
<h2 class="text-xl font-bold">{gettext("No open activities yet")}</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("No open activities in this area")}</h2>
|
||||
<p class="mt-2 text-base-content/60">
|
||||
{gettext("Create a clear plan in a public place.")}
|
||||
{gettext("Move or zoom the map to search somewhere else.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -283,32 +478,81 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
</div>
|
||||
|
||||
<aside>
|
||||
<div class="request-discovery-map-shell">
|
||||
<div
|
||||
id="activity-map"
|
||||
phx-hook="AidMap"
|
||||
phx-update="ignore"
|
||||
data-markers={@markers}
|
||||
data-discovery-map="true"
|
||||
data-auto-fit="false"
|
||||
data-items={@map_items}
|
||||
data-viewport={viewport_json(@viewport)}
|
||||
data-item-path-prefix="/activities"
|
||||
data-item-label={gettext("Open activity")}
|
||||
data-cluster-label={gettext("%{count} activities", count: "%{count}")}
|
||||
data-map-unavailable-label={
|
||||
gettext("The map is unavailable in this browser. Request details remain usable.")
|
||||
gettext("The map is unavailable in this browser. Activity details remain usable.")
|
||||
}
|
||||
data-map-retry-label={gettext("Try map again")}
|
||||
data-map-recenter-label={gettext("Show location on map")}
|
||||
class="aid-map"
|
||||
/>
|
||||
<button
|
||||
:if={@pending_viewport}
|
||||
id="search-activity-map-area"
|
||||
type="button"
|
||||
phx-click="search-map-area"
|
||||
class="btn btn-info request-map-search-button"
|
||||
>
|
||||
<.icon name="hero-magnifying-glass" class="size-4" />
|
||||
{gettext("Search this area")}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-base-content/60">
|
||||
{ngettext(
|
||||
"%{count} activity in this map area",
|
||||
"%{count} activities in this map area",
|
||||
@map_result_count,
|
||||
count: @map_result_count
|
||||
)}
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section :if={@my_activities != []} class="mt-12">
|
||||
<h2 class="text-2xl font-bold">{gettext("Your activities")}</h2>
|
||||
<section :if={@discovery_mode == "mine"} id="my-activities" class="mt-7">
|
||||
<div
|
||||
:if={@my_activities == []}
|
||||
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
||||
>
|
||||
<.icon name="hero-user-group" class="mx-auto size-8 text-info" />
|
||||
<h2 class="mt-3 text-xl font-bold">{gettext("No activities yet")}</h2>
|
||||
<p class="mx-auto mt-2 max-w-lg text-base-content/60">
|
||||
{gettext(
|
||||
"Activities you create or ask to join will appear here. Leaving removes an activity from this list."
|
||||
)}
|
||||
</p>
|
||||
<.link navigate={~p"/activities/new"} class="btn btn-info mt-5">
|
||||
{gettext("Create an activity")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<.link
|
||||
:for={activity <- @my_activities}
|
||||
id={"my-activity-#{activity.id}"}
|
||||
navigate={~p"/activities/#{activity.id}"}
|
||||
class="rounded-2xl bg-base-200 p-4"
|
||||
class="rounded-2xl border border-base-300 bg-base-100 p-4 transition hover:border-info/50"
|
||||
>
|
||||
<span class="badge badge-sm">{status_label(activity.status)}</span>
|
||||
<div class="mt-2 font-semibold">{activity.title}</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
<.local_time
|
||||
id={"my-activity-start-#{activity.id}"}
|
||||
value={activity.starts_at}
|
||||
date_style="medium"
|
||||
time_style="short"
|
||||
/>
|
||||
</div>
|
||||
</.link>
|
||||
</div>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
alias WhoNeedHelp.{Help, Pagination, Trust}
|
||||
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||
|
||||
@discovery_modes ~w(nearby mine)
|
||||
@view_modes ~w(list split map)
|
||||
@filter_keys ~w(category_id urgency area)
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
socket
|
||||
|> assign(:filters, filters)
|
||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||
|> assign(:discovery_mode, "nearby")
|
||||
|> assign(:view_mode, "split")
|
||||
|> assign(:view_explicit, false)
|
||||
|> assign(:viewport, nil)
|
||||
|
|
@ -37,17 +39,22 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
filters = normalize_filters(params)
|
||||
viewport = parse_viewport(params)
|
||||
view_mode = normalize_view_mode(params["view"])
|
||||
discovery_mode = normalize_discovery_mode(params["scope"])
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:filters, filters)
|
||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||
|> assign(:discovery_mode, discovery_mode)
|
||||
|> assign(:view_mode, view_mode)
|
||||
|> assign(:view_explicit, params["view"] in @view_modes)
|
||||
|> assign(:viewport, viewport)
|
||||
|> assign(:pending_viewport, nil)
|
||||
|> assign(:query_params, normalize_query_params(filters, viewport, view_mode))
|
||||
|> refresh_discovery_subscription(viewport, filters)
|
||||
|> assign(
|
||||
:query_params,
|
||||
normalize_query_params(filters, viewport, view_mode, discovery_mode)
|
||||
)
|
||||
|> refresh_discovery_subscription(viewport, filters, discovery_mode)
|
||||
|> load()
|
||||
|
||||
{:noreply, socket}
|
||||
|
|
@ -63,6 +70,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
{:noreply, push_patch(socket, to: request_path(socket, view_mode: view_mode))}
|
||||
end
|
||||
|
||||
def handle_event("set-scope", %{"scope" => discovery_mode}, socket)
|
||||
when discovery_mode in @discovery_modes do
|
||||
{:noreply, push_patch(socket, to: request_path(socket, discovery_mode: discovery_mode))}
|
||||
end
|
||||
|
||||
def handle_event("restore-view-mode", %{"view" => view_mode}, socket)
|
||||
when view_mode in @view_modes do
|
||||
if socket.assigns.view_explicit do
|
||||
|
|
@ -154,8 +166,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
|
||||
defp load(socket) do
|
||||
{requests_page, map_items} =
|
||||
case socket.assigns.viewport do
|
||||
%DiscoveryViewport{} = viewport ->
|
||||
case {socket.assigns.discovery_mode, socket.assigns.viewport} do
|
||||
{"mine", _viewport} ->
|
||||
{%Pagination.Page{}, []}
|
||||
|
||||
{"nearby", %DiscoveryViewport{} = viewport} ->
|
||||
page =
|
||||
Help.paginate_open_requests(socket.assigns.current_scope, socket.assigns.filters,
|
||||
viewport: viewport
|
||||
|
|
@ -170,11 +185,17 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
|
||||
{page, items}
|
||||
|
||||
nil ->
|
||||
{"nearby", nil} ->
|
||||
{%Pagination.Page{}, []}
|
||||
end
|
||||
|
||||
my_requests_page = Help.paginate_my_requests(socket.assigns.current_scope)
|
||||
my_requests_page =
|
||||
if socket.assigns.discovery_mode == "mine" do
|
||||
Help.paginate_my_requests(socket.assigns.current_scope)
|
||||
else
|
||||
%Pagination.Page{}
|
||||
end
|
||||
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
socket
|
||||
|
|
@ -187,16 +208,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
|> assign(:map_result_count, Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)))
|
||||
end
|
||||
|
||||
defp refresh_discovery_subscription(socket, viewport, filters) do
|
||||
defp refresh_discovery_subscription(socket, viewport, filters, discovery_mode) do
|
||||
if connected?(socket) do
|
||||
:ok = Help.unsubscribe_discovery(socket.assigns.discovery_topics)
|
||||
|
||||
topics =
|
||||
case viewport do
|
||||
%DiscoveryViewport{} = viewport ->
|
||||
case {discovery_mode, viewport} do
|
||||
{"nearby", %DiscoveryViewport{} = viewport} ->
|
||||
Help.subscribe_discovery(viewport, String.trim(filters["area"]) != "")
|
||||
|
||||
nil ->
|
||||
_mine_or_missing_viewport ->
|
||||
[]
|
||||
end
|
||||
|
||||
|
|
@ -236,6 +257,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
defp normalize_view_mode(view_mode) when view_mode in @view_modes, do: view_mode
|
||||
defp normalize_view_mode(_view_mode), do: "split"
|
||||
|
||||
defp normalize_discovery_mode(discovery_mode) when discovery_mode in @discovery_modes,
|
||||
do: discovery_mode
|
||||
|
||||
defp normalize_discovery_mode(_discovery_mode), do: "nearby"
|
||||
|
||||
defp parse_viewport(params) do
|
||||
case DiscoveryViewport.cast(params) do
|
||||
{:ok, viewport} -> viewport
|
||||
|
|
@ -243,18 +269,27 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
defp normalize_query_params(filters, viewport, view_mode) do
|
||||
defp normalize_query_params(filters, viewport, view_mode, discovery_mode) do
|
||||
filters
|
||||
|> Map.reject(fn {_key, value} -> value == "" end)
|
||||
|> Map.put("view", view_mode)
|
||||
|> Map.merge(if(viewport, do: DiscoveryViewport.query_params(viewport), else: %{}))
|
||||
|> then(fn params ->
|
||||
if discovery_mode == "mine", do: Map.put(params, "scope", "mine"), else: params
|
||||
end)
|
||||
|> Map.merge(
|
||||
if(discovery_mode == "nearby" && viewport,
|
||||
do: DiscoveryViewport.query_params(viewport),
|
||||
else: %{}
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp request_path(socket, overrides) do
|
||||
filters = Keyword.get(overrides, :filters, socket.assigns.filters)
|
||||
viewport = Keyword.get(overrides, :viewport, socket.assigns.viewport)
|
||||
view_mode = Keyword.get(overrides, :view_mode, socket.assigns.view_mode)
|
||||
params = normalize_query_params(filters, viewport, view_mode)
|
||||
discovery_mode = Keyword.get(overrides, :discovery_mode, socket.assigns.discovery_mode)
|
||||
params = normalize_query_params(filters, viewport, view_mode, discovery_mode)
|
||||
|
||||
~p"/requests?#{params}"
|
||||
end
|
||||
|
|
@ -317,7 +352,34 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
class="mt-5 grid grid-cols-2 gap-1 rounded-2xl border border-base-300 bg-base-200 p-1"
|
||||
aria-label={gettext("Request views")}
|
||||
>
|
||||
<button
|
||||
:for={
|
||||
{scope, label, icon} <- [
|
||||
{"nearby", gettext("Nearby requests"), "hero-map-pin"},
|
||||
{"mine", gettext("My requests"), "hero-user-circle"}
|
||||
]
|
||||
}
|
||||
type="button"
|
||||
phx-click="set-scope"
|
||||
phx-value-scope={scope}
|
||||
aria-pressed={to_string(@discovery_mode == scope)}
|
||||
class={[
|
||||
"btn min-h-11 min-w-0 border-0 px-2 sm:px-4",
|
||||
@discovery_mode == scope && "bg-base-100 text-primary shadow-sm",
|
||||
@discovery_mode != scope && "btn-ghost text-base-content/65"
|
||||
]}
|
||||
>
|
||||
<.icon name={icon} class="size-5 shrink-0" />
|
||||
<span class="truncate">{label}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<.form
|
||||
:if={@discovery_mode == "nearby"}
|
||||
for={@filter_form}
|
||||
id="request-filters"
|
||||
phx-change="filter"
|
||||
|
|
@ -358,6 +420,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
</.form>
|
||||
|
||||
<section
|
||||
:if={@discovery_mode == "nearby"}
|
||||
id="request-discovery"
|
||||
phx-hook="RequestDiscovery"
|
||||
data-view-mode={@view_mode}
|
||||
|
|
@ -519,7 +582,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
gettext("The map is unavailable in this browser. Request details remain usable.")
|
||||
}
|
||||
data-map-retry-label={gettext("Try map again")}
|
||||
data-request-label={gettext("Open request")}
|
||||
data-item-label={gettext("Open request")}
|
||||
data-cluster-label={gettext("%{count} requests", count: "%{count}")}
|
||||
class="aid-map request-discovery-map"
|
||||
/>
|
||||
<button
|
||||
|
|
@ -555,17 +619,47 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section :if={@my_requests != []} class="mt-12">
|
||||
<h2 class="text-2xl font-bold">{gettext("Your requests")}</h2>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<section :if={@discovery_mode == "mine"} id="my-requests" class="mt-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black">{gettext("My requests")}</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
{gettext("Track requests you created and requests you accepted as a helper.")}
|
||||
</p>
|
||||
</div>
|
||||
<.link navigate={~p"/requests/new"} class="btn btn-primary btn-sm hidden sm:inline-flex">
|
||||
{gettext("Create request")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@my_requests == []}
|
||||
class="mt-4 rounded-3xl border border-dashed border-base-300 p-8 text-center sm:p-10"
|
||||
>
|
||||
<.icon name="hero-clipboard-document-list" class="mx-auto size-9 text-success" />
|
||||
<h3 class="mt-3 text-xl font-bold">{gettext("No requests yet")}</h3>
|
||||
<p class="mx-auto mt-2 max-w-md text-base-content/60">
|
||||
{gettext("Requests you publish or accept will appear here.")}
|
||||
</p>
|
||||
<.link navigate={~p"/requests/new"} class="btn btn-primary mt-5">
|
||||
{gettext("Create your first request")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div :if={@my_requests != []} class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<.link
|
||||
:for={request <- @my_requests}
|
||||
id={"my-request-#{request.id}"}
|
||||
navigate={~p"/requests/#{request.id}"}
|
||||
class="rounded-2xl bg-base-200 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<span class="badge badge-sm">{status_label(request.status)}</span>
|
||||
<div class="mt-2 font-semibold">{request.title}</div>
|
||||
<div class="mt-2 truncate font-semibold">{request.title}</div>
|
||||
</div>
|
||||
<.icon name="hero-chevron-right" class="mt-1 size-5 shrink-0 opacity-45" />
|
||||
</div>
|
||||
</.link>
|
||||
</div>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -270,6 +270,32 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
phx-submit="save"
|
||||
class="mt-8 space-y-5"
|
||||
>
|
||||
<nav class="request-form-progress" aria-label={gettext("Request form steps")}>
|
||||
<a
|
||||
:for={
|
||||
{number, label, target} <- [
|
||||
{1, gettext("Help details"), "#request-step-details"},
|
||||
{2, gettext("Timing"), "#request-step-timing"},
|
||||
{3, gettext("Location"), "#request-location-workspace"},
|
||||
{4, gettext("Review"), "#request-step-review"}
|
||||
]
|
||||
}
|
||||
href={target}
|
||||
>
|
||||
<span>{number}</span>
|
||||
{label}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<section id="request-step-details" class="request-form-section scroll-mt-28">
|
||||
<div class="request-form-section-heading">
|
||||
<span>1</span>
|
||||
<div>
|
||||
<h2>{gettext("What help do you need?")}</h2>
|
||||
<p>{gettext("Choose a category and describe the task clearly.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<.input
|
||||
field={@form[:category_id]}
|
||||
type="select"
|
||||
|
|
@ -347,7 +373,10 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
<option value="true" selected={structured_value(@form, field["key"]) == "true"}>
|
||||
{gettext("Yes")}
|
||||
</option>
|
||||
<option value="false" selected={structured_value(@form, field["key"]) == "false"}>
|
||||
<option
|
||||
value="false"
|
||||
selected={structured_value(@form, field["key"]) == "false"}
|
||||
>
|
||||
{gettext("No")}
|
||||
</option>
|
||||
</select>
|
||||
|
|
@ -401,6 +430,17 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="request-step-timing" class="request-form-section scroll-mt-28">
|
||||
<div class="request-form-section-heading">
|
||||
<span>2</span>
|
||||
<div>
|
||||
<h2>{gettext("When is help needed?")}</h2>
|
||||
<p>{gettext("Set urgency and when this request should close.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<.input
|
||||
field={@form[:urgency]}
|
||||
|
|
@ -423,6 +463,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
id="request-location-workspace"
|
||||
phx-hook="RequestLocationPicker"
|
||||
|
|
@ -444,10 +485,16 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
data-location-unavailable-label={gettext("Location is unavailable in this browser.")}
|
||||
data-map-retry-label={gettext("Try map again")}
|
||||
data-location-denied-label={gettext("Location permission was denied.")}
|
||||
class="request-location-workspace"
|
||||
class="request-form-section request-location-workspace scroll-mt-28"
|
||||
>
|
||||
<div class="request-form-section-heading">
|
||||
<span>3</span>
|
||||
<div>
|
||||
<h2>{gettext("Where is help needed?")}</h2>
|
||||
<p>{gettext("Share only as much location detail as helpers need.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-black">{gettext("Where is help needed?")}</h2>
|
||||
<.input
|
||||
field={@form[:location_label]}
|
||||
label={gettext("Area or public landmark")}
|
||||
|
|
@ -658,6 +705,14 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
{message}
|
||||
</p>
|
||||
</section>
|
||||
<section id="request-step-review" class="request-form-section scroll-mt-28">
|
||||
<div class="request-form-section-heading">
|
||||
<span>4</span>
|
||||
<div>
|
||||
<h2>{gettext("Review and publish")}</h2>
|
||||
<p>{gettext("Confirm the safety guidance before asking nearby people for help.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-base-300 p-4 text-sm">
|
||||
<.input
|
||||
field={@form[:safety_confirmed]}
|
||||
|
|
@ -672,7 +727,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
id="request-submit-errors"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
class="alert alert-error items-start"
|
||||
class="alert alert-error mt-4 items-start"
|
||||
>
|
||||
<.icon name="hero-exclamation-triangle" class="mt-0.5 size-5 shrink-0" />
|
||||
<div>
|
||||
|
|
@ -684,11 +739,12 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
</div>
|
||||
</div>
|
||||
<.button
|
||||
class="btn btn-primary btn-lg w-full"
|
||||
class="btn btn-primary btn-lg request-form-publish mt-5 w-full"
|
||||
phx-disable-with={gettext("Publishing…")}
|
||||
>
|
||||
{gettext("Publish request")}
|
||||
</.button>
|
||||
</section>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
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
|
|
@ -5,6 +5,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
|
||||
alias WhoNeedHelp.{Activities, Catalog, Trust}
|
||||
alias WhoNeedHelp.Activities.Activity
|
||||
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.Trust.AuditEvent
|
||||
|
||||
|
|
@ -351,6 +352,106 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
end)
|
||||
end
|
||||
|
||||
test "activity discovery loads only the selected map viewport", context do
|
||||
{:ok, nearby} =
|
||||
Activities.create_activity(
|
||||
context.organizer_scope,
|
||||
context.attrs
|
||||
|> Map.put("title", "Nearby activity")
|
||||
|> Map.put("latitude", "50.4501")
|
||||
|> Map.put("longitude", "30.5234")
|
||||
)
|
||||
|
||||
{:ok, far_away} =
|
||||
Activities.create_activity(
|
||||
context.organizer_scope,
|
||||
context.attrs
|
||||
|> Map.put("title", "Far-away activity")
|
||||
|> Map.put("latitude", "52.5200")
|
||||
|> Map.put("longitude", "13.4050")
|
||||
)
|
||||
|
||||
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
|
||||
|
||||
ids =
|
||||
context.outsider_scope
|
||||
|> Activities.paginate_open_activities(%{}, viewport: viewport, limit: 100)
|
||||
|> Map.fetch!(:entries)
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
assert nearby.id in ids
|
||||
refute far_away.id in ids
|
||||
end
|
||||
|
||||
test "activity map discovery clusters dense points and keeps sparse points", context do
|
||||
for {offset, index} <- Enum.with_index([0.0, 0.00005, 0.0001], 1) do
|
||||
assert {:ok, _activity} =
|
||||
Activities.create_activity(
|
||||
context.organizer_scope,
|
||||
context.attrs
|
||||
|> Map.put("title", "Dense activity #{index}")
|
||||
|> Map.put("longitude", Float.to_string(30.5234 + offset))
|
||||
)
|
||||
end
|
||||
|
||||
{:ok, sparse} =
|
||||
Activities.create_activity(
|
||||
context.organizer_scope,
|
||||
context.attrs
|
||||
|> Map.put("title", "Sparse activity")
|
||||
|> Map.put("latitude", "50.8")
|
||||
|> Map.put("longitude", "30.8")
|
||||
)
|
||||
|
||||
items =
|
||||
Activities.map_discovery_items(
|
||||
context.outsider_scope,
|
||||
%{},
|
||||
viewport!(30.0, 50.0, 31.0, 51.0, 9)
|
||||
)
|
||||
|
||||
assert Enum.any?(items, &match?(%{type: "cluster", count: 3}, &1))
|
||||
|
||||
assert Enum.any?(items, fn
|
||||
%{type: "activity", id: id} -> id == sparse.id
|
||||
_item -> false
|
||||
end)
|
||||
end
|
||||
|
||||
test "hidden activity is discoverable only through its public area description", context do
|
||||
{:ok, hidden} =
|
||||
Activities.create_activity(
|
||||
context.organizer_scope,
|
||||
context.attrs
|
||||
|> Map.put("title", "Hidden-map activity")
|
||||
|> Map.put("location_label", "Kyiv Podil riverside")
|
||||
|> Map.put("location_visibility", "hidden")
|
||||
)
|
||||
|
||||
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
|
||||
|
||||
without_area =
|
||||
Activities.paginate_open_activities(context.outsider_scope, %{},
|
||||
viewport: viewport,
|
||||
limit: 100
|
||||
)
|
||||
|
||||
refute Enum.any?(without_area.entries, &(&1.id == hidden.id))
|
||||
|
||||
with_area =
|
||||
Activities.paginate_open_activities(
|
||||
context.outsider_scope,
|
||||
%{"area" => "Podil"},
|
||||
viewport: viewport,
|
||||
limit: 100
|
||||
)
|
||||
|
||||
assert Enum.any?(with_area.entries, &(&1.id == hidden.id))
|
||||
|
||||
assert Activities.map_discovery_items(context.outsider_scope, %{"area" => "Podil"}, viewport) ==
|
||||
[]
|
||||
end
|
||||
|
||||
test "my activities are derived from the organizer and participant membership rows", context do
|
||||
{:ok, organized} = Activities.create_activity(context.organizer_scope, context.attrs)
|
||||
{:ok, request} = Activities.request_to_join(context.participant_scope, organized.id)
|
||||
|
|
@ -474,6 +575,21 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
)
|
||||
end
|
||||
|
||||
defp viewport!(west, south, east, north, zoom) do
|
||||
{:ok, viewport} =
|
||||
DiscoveryViewport.cast(%{
|
||||
west: west,
|
||||
south: south,
|
||||
east: east,
|
||||
north: north,
|
||||
zoom: zoom,
|
||||
width: 1_200,
|
||||
height: 720
|
||||
})
|
||||
|
||||
viewport
|
||||
end
|
||||
|
||||
test "moderators can hide and restore a reported activity", context do
|
||||
moderator =
|
||||
user_fixture(display_name: "Activity moderator")
|
||||
|
|
|
|||
|
|
@ -42,6 +42,17 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
request_attrs: request_attrs}
|
||||
end
|
||||
|
||||
test "my requests includes requests created by the user and requests accepted as helper",
|
||||
context do
|
||||
unrelated_user = user_fixture(display_name: "Unrelated member")
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
{:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
|
||||
assert Enum.map(Help.list_my_requests(context.requester_scope), & &1.id) == [request.id]
|
||||
assert Enum.map(Help.list_my_requests(context.helper_scope), & &1.id) == [request.id]
|
||||
assert Help.list_my_requests(user_scope_fixture(unrelated_user)) == []
|
||||
end
|
||||
|
||||
test "requester and helper complete a verified handover", context do
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
{:error, :own_request} = Help.accept_request(context.requester_scope, request.id)
|
||||
|
|
|
|||
|
|
@ -225,6 +225,51 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
assert html =~ "Створити термінову заявку"
|
||||
end
|
||||
|
||||
test "request discovery separates nearby requests from the user's own work", %{
|
||||
conn: conn,
|
||||
scope: scope
|
||||
} do
|
||||
category = Catalog.seed_defaults()
|
||||
{:ok, request} = Help.create_request(scope, request_attrs(category))
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/requests")
|
||||
assert has_element?(view, "#request-discovery")
|
||||
refute has_element?(view, "#my-requests")
|
||||
|
||||
view
|
||||
|> element("button[phx-click='set-scope'][phx-value-scope='mine']")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#my-requests")
|
||||
assert has_element?(view, "#my-request-#{request.id}")
|
||||
refute has_element?(view, "#request-discovery")
|
||||
end
|
||||
|
||||
test "activity discovery separates nearby plans from the user's participation", %{
|
||||
conn: conn,
|
||||
scope: scope
|
||||
} do
|
||||
Catalog.seed_defaults()
|
||||
|
||||
category =
|
||||
Catalog.list_categories(:activity)
|
||||
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
||||
|
||||
{:ok, activity} = Activities.create_activity(scope, activity_attrs(category))
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/activities")
|
||||
assert has_element?(view, "#activity-map")
|
||||
refute has_element?(view, "#my-activities")
|
||||
|
||||
view
|
||||
|> element("button[phx-click='set-scope'][phx-value-scope='mine']")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#my-activities")
|
||||
assert has_element?(view, "#my-activity-#{activity.id}")
|
||||
refute has_element?(view, "#activity-map")
|
||||
end
|
||||
|
||||
test "profile social-link creation uses the shared action limiter", %{conn: conn} do
|
||||
previous = Application.get_env(:who_need_help, :rate_limit_policies)
|
||||
|
||||
|
|
@ -334,7 +379,11 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
Catalog.list_categories(:activity)
|
||||
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
||||
|
||||
{:ok, view, _html} = build_conn() |> log_in_user(viewer) |> live(~p"/activities")
|
||||
{:ok, view, _html} =
|
||||
build_conn()
|
||||
|> log_in_user(viewer)
|
||||
|> live(activities_in_kyiv_path())
|
||||
|
||||
assert has_element?(view, "#activity-map[data-map-recenter-label='Show location on map']")
|
||||
|
||||
{:ok, _activity} =
|
||||
|
|
@ -365,7 +414,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
Catalog.list_categories(:activity)
|
||||
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
||||
|
||||
{:ok, view, _html} = build_conn() |> log_in_user(viewer) |> live(~p"/activities")
|
||||
{:ok, view, _html} =
|
||||
build_conn()
|
||||
|> log_in_user(viewer)
|
||||
|> live(activities_in_kyiv_path())
|
||||
|
||||
activities =
|
||||
for index <- 1..25 do
|
||||
|
|
@ -440,6 +492,22 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
refute html =~ "pickup_status is required"
|
||||
end
|
||||
|
||||
test "new request form groups the mobile journey into stable jump steps", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/requests/new")
|
||||
|
||||
for id <- [
|
||||
"request-step-details",
|
||||
"request-step-timing",
|
||||
"request-location-workspace",
|
||||
"request-step-review"
|
||||
] do
|
||||
assert has_element?(view, "##{id}.request-form-section")
|
||||
assert has_element?(view, ".request-form-progress a[href='##{id}']")
|
||||
end
|
||||
|
||||
assert has_element?(view, ".request-form-publish", "Publish request")
|
||||
end
|
||||
|
||||
test "category descriptions use the current Ukrainian locale", %{conn: conn} do
|
||||
category = Catalog.seed_defaults()
|
||||
conn = get(conn, ~p"/?locale=uk")
|
||||
|
|
@ -1468,6 +1536,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
~p"/requests?#{%{west: 30.0, south: 50.0, east: 31.0, north: 51.0, zoom: 10, width: 1200, height: 720, view: "split"}}"
|
||||
end
|
||||
|
||||
defp activities_in_kyiv_path do
|
||||
~p"/activities?#{%{west: 30.0, south: 50.0, east: 31.0, north: 51.0, zoom: 10, width: 1200, height: 720}}"
|
||||
end
|
||||
|
||||
defp assert_eventually(fun, attempts \\ 50)
|
||||
|
||||
defp assert_eventually(fun, attempts) when attempts > 0 do
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user