Improve mobile discovery and request creation

This commit is contained in:
SimpleTest 2026-07-25 15:27:28 +03:00
parent ae663a745b
commit 13fbeec958
14 changed files with 2429 additions and 900 deletions

View File

@ -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;
}

View File

@ -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({

View File

@ -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)

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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