feat: localize the complete product experience
This commit is contained in:
parent
8fbac386e7
commit
0d4fd82702
|
|
@ -3,7 +3,14 @@
|
|||
# public constructors are used. Keep these filters path- and warning-specific;
|
||||
# `list_unused_filters: true` makes Dialyxir fail when either workaround is no
|
||||
# longer needed.
|
||||
#
|
||||
# Gettext compiles the non-English three-form plural rule into the generated
|
||||
# backend module. Dialyzer sees Expo.PluralForms.plural_ast/0 through
|
||||
# Gettext.Plural.plural/2 as an opaque-value mismatch even though both values
|
||||
# are constructed by those libraries. This filter is limited to that generated
|
||||
# backend path and the same warning class.
|
||||
[
|
||||
{"lib/who_need_help/help.ex", :call_without_opaque},
|
||||
{"lib/who_need_help/tracking.ex", :call_without_opaque}
|
||||
{"lib/who_need_help/tracking.ex", :call_without_opaque},
|
||||
{"lib/who_need_help_web/gettext.ex", :call_without_opaque}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -174,7 +174,9 @@ group chat, message-scoped reporting, blocking, privacy defaults, an unverified
|
|||
social link, category moderation, report resolution, and account restriction.
|
||||
The same gate checks keyboard skip navigation, WCAG violations and contrast on
|
||||
four public pages in both themes, horizontal overflow at three viewport widths,
|
||||
an actual locally served raster map tile, and LiveView offline/reconnect UI.
|
||||
an actual locally served raster map tile, LiveView offline/reconnect UI, public
|
||||
RU/UK locale persistence, and the selected Ukrainian locale in an authenticated
|
||||
LiveView.
|
||||
An optional list of Playwright spec paths can be passed after the command for a
|
||||
focused diagnostic run.
|
||||
It retains traces, screenshots, video, and Compose logs under the ignored
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ item below unless the evidence column explicitly describes a local mock.
|
|||
| Browser E2E | Manual headed-Chrome scenarios exist; no committed browser suite | A fresh uniquely named Compose project runs two-user urgent help, Activity, moderation, privacy, and error paths; traces are retained on failure; its exact volume is removed |
|
||||
| Android UI | Two JVM unit-test files; no `androidTest` source set | Emulator instrumentation covers deep links, permissions, foreground tracking, notification Stop, lifecycle, and network failure |
|
||||
| CI and quality | No tracked CI workflow or static/security analysis dependencies | The same containerized gates pass locally and are represented in a validated CI workflow |
|
||||
| Localization and accessibility | Accessibility/keyboard/responsive/contrast browser gates pass; most product copy remains hard-coded English | EN/UK/RU catalogs cover product UI; extraction is current; the existing browser gates remain green |
|
||||
| Localization and accessibility | Completed locally: product copy and custom validation messages are extracted; EN/UK/RU catalogs and localized category descriptions/structured values are implemented | 508 default and 40 error messages are current; RU/UK have no empty/fuzzy entries; 153 backend tests and all 8 browser specs pass, including locale persistence, keyboard, axe, themes, responsive widths, and reconnect |
|
||||
| Database scale | Core discovery/chat/moderation lists call unbounded `Repo.all()` | Cursor-bounded queries pass behavior tests and measured `EXPLAIN ANALYZE` checks on an isolated generated dataset |
|
||||
| Load and resilience | Public/readiness/heartbeat k6 profile exists | Authenticated writes, chat, tracking, reconnect, rolling replacement, and worker retry profiles pass without touching staging data |
|
||||
| Observability | Protected Prometheus text endpoint exists | Local Prometheus/Grafana/Alertmanager profile scrapes every replica and an induced isolated failure exercises alert delivery |
|
||||
| Backup | Validated local custom-format dump and restore drill exist | An encrypted artifact is uploaded to local S3-compatible MinIO and restored into a fresh database; corruption and interrupted-upload checks fail closed |
|
||||
| External boundaries | Mailpit and a fake GitHub strategy cover parts of SMTP/OAuth | Local protocol-level SMTP/OAuth mocks and the applicable push adapter boundary cover success, rejection, retry, replay, and timeout |
|
||||
| Final regression | 150 Phoenix tests and manual cross-client evidence | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits |
|
||||
| Final regression | 153 Phoenix tests plus reproducible browser and Android device suites | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits |
|
||||
|
||||
The goal remains open while any row lacks reproducible local evidence.
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ The goal remains open while any row lacks reproducible local evidence.
|
|||
- The containerized `scripts/quality.sh` gate passes ShellCheck, Hadolint,
|
||||
actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and
|
||||
the rendered Kubernetes manifest, compiler/xref/Credo/Sobelow/Dialyzer/Hex
|
||||
checks, 150 Phoenix tests, both npm audits, and a Trivy scan of the production
|
||||
checks, 153 Phoenix tests, both npm audits, and a Trivy scan of the production
|
||||
release image. It creates random one-run database credentials and removes its
|
||||
exact volume, networks, images, and source snapshot.
|
||||
- The checked-in GitHub Actions workflow runs the same isolated backend/security
|
||||
|
|
@ -66,6 +66,10 @@ The goal remains open while any row lacks reproducible local evidence.
|
|||
Mailpit, both web replicas, and both worker replicas became Ready as non-root
|
||||
containers with read-only root filesystems, dropped capabilities, and
|
||||
RuntimeDefault seccomp where configured. The cross-node PubSub probe passed.
|
||||
- The remaining rows above are still pending. In particular, complete EN/UK/RU
|
||||
extraction and catalogs are not implied by the completed accessibility
|
||||
checks; this document is not a completion claim.
|
||||
- EN/UK/RU extraction and catalogs are now complete for current product UI and
|
||||
custom validation messages. Locale selection persists for public pages and
|
||||
authenticated LiveViews; category descriptions and structured values follow
|
||||
that browser locale. The localization browser specs run in the same isolated
|
||||
two-web/two-worker gate as the accessibility checks.
|
||||
- The remaining rows above are still pending; this document is not a
|
||||
completion claim for the entire hardening goal.
|
||||
|
|
|
|||
|
|
@ -16,27 +16,29 @@ results from product limits and unknown production properties.
|
|||
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
|
||||
| Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. |
|
||||
| Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. |
|
||||
| Social profiles | Manual links implemented; GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record; 150 tests pass, including callback replay/state checks. No access-token field exists and the controller receives only normalized identity attributes. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. Other providers remain manual/unverified. |
|
||||
| Social profiles | Manual links implemented; GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record; 153 tests pass, including callback replay/state checks. No access-token field exists and the controller receives only normalized identity attributes. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. Other providers remain manual/unverified. |
|
||||
| Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. |
|
||||
| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Public HTTPS login, map, two-way chat, permission prompts, minimized foreground-service location updates, notification Stop, deep-link routing, and server cleanup were exercised on API 37. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. |
|
||||
| Multiple web/worker instances | Implemented and locally verified | Docker Compose and kind each ran 2 web and 2 worker replicas. The project probes cross-node Phoenix PubSub using different BEAM nodes. Kubernetes web/worker pods were Ready with zero restarts at the final observation. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
|
||||
|
||||
## Reproducible checks
|
||||
|
||||
- `./scripts/test.sh`: 150 tests, 0 failures after the Android public-staging and
|
||||
realtime tracking-evidence changes
|
||||
- The isolated `./scripts/quality.sh` run completed on 2026-07-19 with 153
|
||||
tests and 0 failures after the full localization changes
|
||||
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
|
||||
- `mix compile --force --warnings-as-errors` and
|
||||
`mix format --check-formatted`: passed against the same final source.
|
||||
- `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning
|
||||
threshold, actionlint 1.7.12, all four Compose renders, Helm lint, Trivy
|
||||
source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow
|
||||
strict/private checks, Hex audit, 150 Phoenix tests, both npm audits, and the
|
||||
strict/private checks, Hex audit, 153 Phoenix tests, both npm audits, and the
|
||||
production-image vulnerability scan. The rendered Helm manifest and Debian
|
||||
13.6 release image each reported zero HIGH/CRITICAL findings under the
|
||||
configured gates.
|
||||
- Dialyzer passed with two exact, documented filters and zero unused filters.
|
||||
Both findings are opaque-type warnings at Ecto `Multi`/`MapSet` call sites;
|
||||
- Dialyzer passed with three path- and warning-specific documented filters and
|
||||
zero unused filters. Two findings are opaque-type warnings at Ecto
|
||||
`Multi`/`MapSet` call sites; the third is the generated Gettext backend's
|
||||
three-form plural-rule opaque-value warning;
|
||||
the filters are visible in `.dialyzer_ignore.exs` rather than hidden by a
|
||||
broad pattern. This is not a claim that third-party dependency compilation is
|
||||
warning-free.
|
||||
|
|
@ -64,13 +66,23 @@ results from product limits and unknown production properties.
|
|||
containers also used read-only root filesystems and dropped capabilities.
|
||||
The cross-node probe observed all four BEAM nodes, and live/readiness returned
|
||||
`ok`/`ready`. Database counts remained `0 users / 0 help requests`.
|
||||
- The committed browser suite passed its 1/1 bootstrap and all 6/6 Chromium
|
||||
specs against a fresh PostGIS volume with two web and two worker replicas.
|
||||
- The committed browser suite passed its 1/1 bootstrap and all 8/8 Chromium
|
||||
specs against a fresh PostGIS volume with two web and two worker replicas on
|
||||
2026-07-19. The retained successful-run artifact directory is
|
||||
`output/e2e/20260719012404-847451`.
|
||||
It covered axe checks across four pages and two themes, keyboard navigation,
|
||||
three responsive widths, Activity/moderation/privacy, the two-user medicine
|
||||
handover and blind reviews, public boundaries, local raster maps, and an
|
||||
offline LiveSocket transport failure/reconnect. The uniquely named Compose
|
||||
volume and networks were removed.
|
||||
offline LiveSocket transport failure/reconnect. It also verified persistent
|
||||
Russian/Ukrainian public language selection and the selected Ukrainian
|
||||
locale inside an authenticated LiveView. The uniquely named Compose volume
|
||||
and networks were removed.
|
||||
- The default localization domain contains 508 current messages and the errors
|
||||
domain contains 40. Russian and Ukrainian catalogs have no empty or fuzzy
|
||||
current entries. Category names, descriptions, structured-field labels and
|
||||
select/boolean values use locale maps with an English fallback; the
|
||||
compatibility `description` column remains as a fallback for pre-existing
|
||||
categories.
|
||||
- Browser verification used headed Chrome. The authenticated matched-request
|
||||
page rendered its chat, MapLibre marker, and live-location controls with no
|
||||
console errors or warnings after the foreground-service rollout.
|
||||
|
|
|
|||
38
e2e/tests/localization.spec.ts
Normal file
38
e2e/tests/localization.spec.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import { newIsolatedContext, registerAndConfirm } from "./helpers";
|
||||
|
||||
test("public language selection persists across pages", async ({ browser }) => {
|
||||
const context = await newIsolatedContext(browser);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/?locale=ru");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "ru");
|
||||
await expect(page.getByRole("heading", { name: "Помощь может быть ближе, чем кажется." })).toBeVisible();
|
||||
|
||||
await page.goto("/safety");
|
||||
await expect(page.getByRole("heading", { name: "Правила безопасности" })).toBeVisible();
|
||||
|
||||
await page.goto("/?locale=uk");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "uk");
|
||||
await expect(page.getByRole("heading", { name: "Допомога може бути ближче, ніж здається." })).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("authenticated LiveView uses the selected browser language", async ({ browser, request }) => {
|
||||
const user = await registerAndConfirm(
|
||||
browser,
|
||||
request,
|
||||
"localization@example.invalid",
|
||||
"E2E Localization",
|
||||
);
|
||||
|
||||
await user.page.goto("/?locale=uk");
|
||||
await user.page.goto("/requests");
|
||||
|
||||
await expect(user.page.locator("html")).toHaveAttribute("lang", "uk");
|
||||
await expect(user.page.getByRole("heading", { name: "Хто потребує допомоги?" })).toBeVisible();
|
||||
await expect(user.page.getByRole("link", { name: "Створити термінову заявку" })).toBeVisible();
|
||||
|
||||
await user.context.close();
|
||||
});
|
||||
|
|
@ -56,8 +56,9 @@ defmodule WhoNeedHelp.Accounts.User do
|
|||
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :display_name, :terms_accepted])
|
||||
|> validate_required([:display_name])
|
||||
|> cast(attrs, [:email, :display_name, :terms_accepted, :locale])
|
||||
|> validate_required([:display_name, :locale])
|
||||
|> validate_inclusion(:locale, ~w(en uk ru))
|
||||
|> validate_acceptance(:terms_accepted,
|
||||
message: "you must confirm that you are 18+ and accept the rules"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
defmodule WhoNeedHelp.Accounts.UserNotifier do
|
||||
use Gettext, backend: WhoNeedHelpWeb.Gettext
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias WhoNeedHelp.Mailer
|
||||
|
|
@ -24,20 +26,17 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
|
|||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
deliver(user.email, "Update email instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can change your email by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
with_user_locale(user, fn ->
|
||||
deliver(
|
||||
user.email,
|
||||
gettext("Update email instructions"),
|
||||
gettext(
|
||||
"Hi %{email},\n\nYou can change your email by visiting the URL below:\n\n%{url}\n\nIf you didn't request this change, please ignore this.",
|
||||
email: user.email,
|
||||
url: url
|
||||
)
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -51,36 +50,38 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
|
|||
end
|
||||
|
||||
defp deliver_magic_link_instructions(user, url) do
|
||||
deliver(user.email, "Log in instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can log into your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this email, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
with_user_locale(user, fn ->
|
||||
deliver(
|
||||
user.email,
|
||||
gettext("Log in instructions"),
|
||||
gettext(
|
||||
"Hi %{email},\n\nYou can log into your account by visiting the URL below:\n\n%{url}\n\nIf you didn't request this email, please ignore this.",
|
||||
email: user.email,
|
||||
url: url
|
||||
)
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
defp deliver_confirmation_instructions(user, url) do
|
||||
deliver(user.email, "Confirmation instructions", """
|
||||
with_user_locale(user, fn ->
|
||||
deliver(
|
||||
user.email,
|
||||
gettext("Confirmation instructions"),
|
||||
gettext(
|
||||
"Hi %{email},\n\nYou can confirm your account by visiting the URL below:\n\n%{url}\n\nIf you didn't create an account with us, please ignore this.",
|
||||
email: user.email,
|
||||
url: url
|
||||
)
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
==============================
|
||||
defp with_user_locale(%User{locale: locale}, fun) when locale in ~w(en uk ru) do
|
||||
Gettext.with_locale(WhoNeedHelpWeb.Gettext, locale, fun)
|
||||
end
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can confirm your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't create an account with us, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
defp with_user_locale(_user, fun) do
|
||||
Gettext.with_locale(WhoNeedHelpWeb.Gettext, "en", fun)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ defmodule WhoNeedHelp.Catalog do
|
|||
category_attrs =
|
||||
category_attrs
|
||||
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|
||||
|> normalize_descriptions()
|
||||
|> Map.put_new("parent_id", proposal.parent_id)
|
||||
|> Map.put_new("mode", to_string(proposal.mode))
|
||||
|
||||
|
|
@ -271,6 +272,16 @@ defmodule WhoNeedHelp.Catalog do
|
|||
defp normalize_structured_value(%{"type" => "boolean"}, "false"), do: false
|
||||
defp normalize_structured_value(_field, value), do: value
|
||||
|
||||
defp normalize_descriptions(%{"descriptions" => descriptions} = attrs)
|
||||
when is_map(descriptions),
|
||||
do: attrs
|
||||
|
||||
defp normalize_descriptions(%{"description" => description} = attrs)
|
||||
when is_binary(description) and description != "",
|
||||
do: Map.put(attrs, "descriptions", %{"en" => description})
|
||||
|
||||
defp normalize_descriptions(attrs), do: attrs
|
||||
|
||||
defp seed_category(attrs) do
|
||||
case Repo.get_by(Category, slug: attrs.slug) do
|
||||
nil ->
|
||||
|
|
@ -281,9 +292,9 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{}
|
||||
|> put_backfill(:names, category.names in [nil, %{}], attrs.names)
|
||||
|> put_backfill(
|
||||
:description,
|
||||
category.description in [nil, ""],
|
||||
attrs.description
|
||||
:descriptions,
|
||||
category.descriptions in [nil, %{}],
|
||||
attrs.descriptions
|
||||
)
|
||||
|> put_backfill(
|
||||
:structured_fields,
|
||||
|
|
@ -328,7 +339,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{
|
||||
slug: "urgent-delivery",
|
||||
names: names("Urgent delivery", "Термінова доставка", "Срочная доставка"),
|
||||
description: "Time-sensitive delivery of an item that is already legally arranged.",
|
||||
descriptions:
|
||||
names(
|
||||
"Time-sensitive delivery of an item that is already legally arranged.",
|
||||
"Термінова доставка речі, отримання якої вже законно узгоджено.",
|
||||
"Срочная доставка вещи, получение которой уже законно согласовано."
|
||||
),
|
||||
active: false,
|
||||
sort_order: 0,
|
||||
structured_fields: %{}
|
||||
|
|
@ -337,8 +353,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{
|
||||
slug: "medicine-pickup",
|
||||
names: names("Medicine pickup", "Доставка ліків", "Доставка лекарств"),
|
||||
description:
|
||||
descriptions:
|
||||
names(
|
||||
"Pickup and deliver a legal medicine that is already purchased or reserved.",
|
||||
"Отримати й доставити дозволені ліки, які вже придбані або зарезервовані.",
|
||||
"Получить и доставить разрешённое лекарство, которое уже куплено или зарезервировано."
|
||||
),
|
||||
sort_order: 10,
|
||||
structured_fields: %{
|
||||
"fields" => [
|
||||
|
|
@ -361,8 +381,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{
|
||||
slug: "roadside-help",
|
||||
names: names("Roadside help", "Допомога в дорозі", "Помощь в дороге"),
|
||||
description:
|
||||
descriptions:
|
||||
names(
|
||||
"Non-emergency roadside assistance after people and vehicles are away from active danger.",
|
||||
"Неекстрена допомога в дорозі після усунення безпосередньої небезпеки для людей і транспорту.",
|
||||
"Неэкстренная помощь в дороге после устранения непосредственной опасности для людей и транспорта."
|
||||
),
|
||||
active: false,
|
||||
sort_order: 100,
|
||||
structured_fields: %{}
|
||||
|
|
@ -371,7 +395,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{
|
||||
slug: "fuel-delivery",
|
||||
names: names("Out of fuel", "Закінчилося пальне", "Закончилось топливо"),
|
||||
description: "Bring a small lawful quantity of fuel to a vehicle in a secured location.",
|
||||
descriptions:
|
||||
names(
|
||||
"Bring a small lawful quantity of fuel to a vehicle in a secured location.",
|
||||
"Привезти невелику дозволену кількість пального до транспорту в безпечному місці.",
|
||||
"Привезти небольшое разрешённое количество топлива к транспорту в безопасном месте."
|
||||
),
|
||||
sort_order: 110,
|
||||
structured_fields: %{
|
||||
"fields" => [
|
||||
|
|
@ -400,8 +429,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{
|
||||
slug: "car-wheel-help",
|
||||
names: names("Car wheel help", "Допомога з колесом авто", "Помощь с колесом авто"),
|
||||
description:
|
||||
descriptions:
|
||||
names(
|
||||
"Help with a flat tyre, inflation, or fitting an available spare in a secured location.",
|
||||
"Допомога з проколом, підкачуванням або встановленням наявного запасного колеса в безпечному місці.",
|
||||
"Помощь с проколом, подкачкой или установкой имеющегося запасного колеса в безопасном месте."
|
||||
),
|
||||
sort_order: 120,
|
||||
structured_fields: %{
|
||||
"fields" => [
|
||||
|
|
@ -445,7 +478,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
"Допомога з велосипедом",
|
||||
"Помощь с велосипедом"
|
||||
),
|
||||
description: "Help with a bicycle puncture, chain, or brake problem.",
|
||||
descriptions:
|
||||
names(
|
||||
"Help with a bicycle puncture, chain, or brake problem.",
|
||||
"Допомога з проколом, ланцюгом або гальмами велосипеда.",
|
||||
"Помощь с проколом, цепью или тормозами велосипеда."
|
||||
),
|
||||
sort_order: 130,
|
||||
structured_fields: %{
|
||||
"fields" => [
|
||||
|
|
@ -486,8 +524,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
"Допомога з мотоциклом",
|
||||
"Помощь с мотоциклом"
|
||||
),
|
||||
description:
|
||||
descriptions:
|
||||
names(
|
||||
"Help with a motorcycle chain, tyre, or battery after the vehicle is secured.",
|
||||
"Допомога з ланцюгом, колесом або акумулятором мотоцикла після його переміщення в безпечне місце.",
|
||||
"Помощь с цепью, колесом или аккумулятором мотоцикла после его перемещения в безопасное место."
|
||||
),
|
||||
sort_order: 140,
|
||||
structured_fields: %{
|
||||
"fields" => [
|
||||
|
|
@ -521,8 +563,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
"Поломка транспортного засобу",
|
||||
"Поломка транспортного средства"
|
||||
),
|
||||
description:
|
||||
descriptions:
|
||||
names(
|
||||
"Non-emergency practical help with a stopped vehicle in a secured location.",
|
||||
"Неекстрена практична допомога із зупиненим транспортом у безпечному місці.",
|
||||
"Неэкстренная практическая помощь с остановившимся транспортом в безопасном месте."
|
||||
),
|
||||
sort_order: 150,
|
||||
structured_fields: %{
|
||||
"fields" => [
|
||||
|
|
@ -573,8 +619,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
"Допомога після дорожньої події",
|
||||
"Помощь после дорожного происшествия"
|
||||
),
|
||||
description:
|
||||
descriptions:
|
||||
names(
|
||||
"Non-emergency presence or practical support only after immediate danger is handled.",
|
||||
"Неекстрена присутність або практична підтримка лише після усунення безпосередньої небезпеки.",
|
||||
"Неэкстренное присутствие или практическая поддержка только после устранения непосредственной опасности."
|
||||
),
|
||||
sort_order: 160,
|
||||
structured_fields: %{
|
||||
"fields" => [
|
||||
|
|
@ -630,8 +680,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{
|
||||
slug: "social-activity",
|
||||
names: names("Social activity", "Спільне дозвілля", "Совместный досуг"),
|
||||
description:
|
||||
descriptions:
|
||||
names(
|
||||
"Optional social plans with organizer-approved participants and no effect on helper reputation.",
|
||||
"Необов’язкові спільні плани з учасниками, яких схвалює організатор, без впливу на репутацію помічника.",
|
||||
"Необязательные совместные планы с участниками, которых одобряет организатор, без влияния на репутацию помощника."
|
||||
),
|
||||
mode: :activity,
|
||||
active: false,
|
||||
sort_order: 1_000,
|
||||
|
|
@ -641,7 +695,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{
|
||||
slug: "coffee-meetup",
|
||||
names: names("Coffee or tea", "Кава або чай", "Кофе или чай"),
|
||||
description: "Meet in a public place for coffee, tea, or conversation.",
|
||||
descriptions:
|
||||
names(
|
||||
"Meet in a public place for coffee, tea, or conversation.",
|
||||
"Зустрітися в публічному місці за кавою, чаєм або розмовою.",
|
||||
"Встретиться в публичном месте за кофе, чаем или разговором."
|
||||
),
|
||||
mode: :activity,
|
||||
sort_order: 1_010,
|
||||
structured_fields: %{
|
||||
|
|
@ -669,7 +728,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{
|
||||
slug: "cinema-meetup",
|
||||
names: names("Cinema", "Кіно", "Кино"),
|
||||
description: "Find company for a public cinema screening.",
|
||||
descriptions:
|
||||
names(
|
||||
"Find company for a public cinema screening.",
|
||||
"Знайти компанію для перегляду фільму в кінотеатрі.",
|
||||
"Найти компанию для просмотра фильма в кинотеатре."
|
||||
),
|
||||
mode: :activity,
|
||||
sort_order: 1_020,
|
||||
structured_fields: %{
|
||||
|
|
@ -687,7 +751,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{
|
||||
slug: "city-walk",
|
||||
names: names("Walk", "Прогулянка", "Прогулка"),
|
||||
description: "Arrange a walk in a public area.",
|
||||
descriptions:
|
||||
names(
|
||||
"Arrange a walk in a public area.",
|
||||
"Домовитися про прогулянку в публічному місці.",
|
||||
"Договориться о прогулке в публичном месте."
|
||||
),
|
||||
mode: :activity,
|
||||
sort_order: 1_030,
|
||||
structured_fields: %{
|
||||
|
|
@ -704,7 +773,12 @@ defmodule WhoNeedHelp.Catalog do
|
|||
%{
|
||||
slug: "hiking-group",
|
||||
names: names("Hiking", "Похід", "Поход"),
|
||||
description: "Plan a group hike with explicit route difficulty and safety preparation.",
|
||||
descriptions:
|
||||
names(
|
||||
"Plan a group hike with explicit route difficulty and safety preparation.",
|
||||
"Спланувати груповий похід із зазначенням складності маршруту та підготовкою з безпеки.",
|
||||
"Спланировать групповой поход с указанием сложности маршрута и подготовкой по безопасности."
|
||||
),
|
||||
mode: :activity,
|
||||
sort_order: 1_040,
|
||||
structured_fields: %{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Catalog.Category do
|
|||
field :slug, :string
|
||||
field :names, :map, default: %{}
|
||||
field :description, :string
|
||||
field :descriptions, :map, default: %{}
|
||||
field :mode, Ecto.Enum, values: [:help, :activity], default: :help
|
||||
field :active, :boolean, default: true
|
||||
field :sort_order, :integer, default: 0
|
||||
|
|
@ -25,6 +26,7 @@ defmodule WhoNeedHelp.Catalog.Category do
|
|||
:slug,
|
||||
:names,
|
||||
:description,
|
||||
:descriptions,
|
||||
:mode,
|
||||
:active,
|
||||
:sort_order,
|
||||
|
|
@ -35,6 +37,7 @@ defmodule WhoNeedHelp.Catalog.Category do
|
|||
|> validate_format(:slug, ~r/^[a-z0-9-]+$/)
|
||||
|> validate_length(:slug, max: 80)
|
||||
|> validate_names()
|
||||
|> validate_descriptions()
|
||||
|> validate_structured_fields()
|
||||
|> unique_constraint(:slug)
|
||||
end
|
||||
|
|
@ -43,6 +46,13 @@ defmodule WhoNeedHelp.Catalog.Category do
|
|||
names[to_string(locale)] || names["en"] || names |> Map.values() |> List.first() || "Help"
|
||||
end
|
||||
|
||||
def description(%__MODULE__{descriptions: descriptions, description: legacy}, locale) do
|
||||
descriptions[to_string(locale)] ||
|
||||
descriptions["en"] ||
|
||||
descriptions |> Map.values() |> List.first() ||
|
||||
legacy
|
||||
end
|
||||
|
||||
defp validate_names(changeset) do
|
||||
validate_change(changeset, :names, fn :names, names ->
|
||||
if is_map(names) and map_size(names) > 0 and
|
||||
|
|
@ -56,6 +66,16 @@ defmodule WhoNeedHelp.Catalog.Category do
|
|||
end)
|
||||
end
|
||||
|
||||
defp validate_descriptions(changeset) do
|
||||
validate_change(changeset, :descriptions, fn :descriptions, descriptions ->
|
||||
if is_map(descriptions) and Enum.all?(descriptions, &valid_label?/1) do
|
||||
[]
|
||||
else
|
||||
[descriptions: "must contain non-empty localized descriptions"]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp validate_structured_fields(changeset) do
|
||||
validate_change(changeset, :structured_fields, fn :structured_fields, schema ->
|
||||
case schema do
|
||||
|
|
|
|||
|
|
@ -1,28 +1,35 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="text-sm font-semibold text-success">OPENAI BUILD WEEK</div>
|
||||
<h1 class="mt-2 text-4xl font-black">Build feedback</h1>
|
||||
<h1 class="mt-2 text-4xl font-black">{gettext("Build feedback")}</h1>
|
||||
<p class="mt-4 text-base-content/65">
|
||||
Who Need Help was developed with the local Codex CLI authenticated through the
|
||||
builder's ChatGPT subscription. No OpenAI API key or usage-based API fallback is used.
|
||||
{gettext(
|
||||
"Who Need Help was developed with the local Codex CLI authenticated through the builder's ChatGPT subscription. No OpenAI API key or usage-based API fallback is used."
|
||||
)}
|
||||
</p>
|
||||
<div class="mt-7 rounded-3xl bg-base-200 p-6">
|
||||
<div class="text-xs font-bold uppercase tracking-wide text-base-content/50">
|
||||
Main Codex session ID
|
||||
{gettext("Main Codex session ID")}
|
||||
</div>
|
||||
<code class="mt-2 block break-all text-lg">{@session_id}</code>
|
||||
</div>
|
||||
<p :if={@session_id == "not-configured"} class="alert alert-warning mt-5 text-sm">
|
||||
Set CODEX_SESSION_ID in the deployment environment before the submission recording.
|
||||
{gettext(
|
||||
"Set CODEX_SESSION_ID in the deployment environment before the submission recording."
|
||||
)}
|
||||
</p>
|
||||
<div class="mt-8 space-y-3 text-sm">
|
||||
<h2 class="text-xl font-bold">What Codex contributed</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("What Codex contributed")}</h2>
|
||||
<p>
|
||||
Product boundaries, architecture decisions, Phoenix contexts, migrations, LiveView flows, Docker/Helm packaging, and verification.
|
||||
{gettext(
|
||||
"Product boundaries, architecture decisions, Phoenix contexts, migrations, LiveView flows, Docker/Helm packaging, and verification."
|
||||
)}
|
||||
</p>
|
||||
<h2 class="pt-3 text-xl font-bold">Human decisions retained</h2>
|
||||
<h2 class="pt-3 text-xl font-bold">{gettext("Human decisions retained")}</h2>
|
||||
<p>
|
||||
Safety policy, category approval, account moderation, public launch, and every production infrastructure change remain human-controlled.
|
||||
{gettext(
|
||||
"Safety policy, category approval, account moderation, public launch, and every production infrastructure change remain human-controlled."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
{gettext("Help can be closer than you think.")}
|
||||
</h1>
|
||||
<p class="mt-6 max-w-2xl text-lg leading-8 text-base-content/70">
|
||||
Who Need Help connects people who urgently need medicine pickup or safe
|
||||
roadside assistance with nearby volunteers who can help for free.
|
||||
{gettext(
|
||||
"Who Need Help connects people who urgently need medicine pickup or safe roadside assistance with nearby volunteers who can help for free."
|
||||
)}
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<.link navigate={~p"/requests"} class="btn btn-primary btn-lg">
|
||||
|
|
@ -26,7 +27,9 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<p class="mt-5 text-sm text-base-content/70">
|
||||
Not an emergency or medical service. In immediate danger, contact local emergency services.
|
||||
{gettext(
|
||||
"Not an emergency or medical service. In immediate danger, contact local emergency services."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -34,19 +37,23 @@
|
|||
<div class="absolute inset-0 rounded-[2rem] bg-success/10 blur-2xl"></div>
|
||||
<div class="relative rounded-[2rem] border border-base-300 bg-base-100 p-7 shadow-xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-success">Medicine pickup</span>
|
||||
<span class="badge badge-error badge-sm">urgent</span>
|
||||
<span class="text-sm font-semibold text-success">{gettext("Medicine pickup")}</span>
|
||||
<span class="badge badge-error badge-sm">{gettext("urgent")}</span>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold">Medicine order is ready at the pharmacy</h2>
|
||||
<h2 class="text-2xl font-bold">{gettext("Medicine order is ready at the pharmacy")}</h2>
|
||||
<p class="mt-3 text-base-content/65">
|
||||
“The pharmacy closes soon and I cannot leave home. The item is already paid for.”
|
||||
{gettext(
|
||||
"The pharmacy closes soon and I cannot leave home. The item is already paid for."
|
||||
)}
|
||||
</p>
|
||||
<div class="mt-7 flex items-center justify-between border-t border-base-300 pt-5">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">Approximate area</div>
|
||||
<div class="text-xs text-base-content/55">Exact address after matching</div>
|
||||
<div class="text-sm font-semibold">{gettext("Approximate area")}</div>
|
||||
<div class="text-xs text-base-content/55">
|
||||
{gettext("Exact address after matching")}
|
||||
</div>
|
||||
<button class="btn btn-success btn-sm" disabled>I can help</button>
|
||||
</div>
|
||||
<button class="btn btn-success btn-sm" disabled>{gettext("I can help")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -55,23 +62,23 @@
|
|||
<section class="grid gap-4 py-10 md:grid-cols-3">
|
||||
<div class="rounded-2xl bg-base-200 p-6">
|
||||
<div class="text-3xl">1</div>
|
||||
<h3 class="mt-4 font-bold">Post a clear request</h3>
|
||||
<h3 class="mt-4 font-bold">{gettext("Post a clear request")}</h3>
|
||||
<p class="mt-2 text-sm text-base-content/65">
|
||||
Choose a category, urgency, and safe location visibility.
|
||||
{gettext("Choose a category, urgency, and safe location visibility.")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-base-200 p-6">
|
||||
<div class="text-3xl">2</div>
|
||||
<h3 class="mt-4 font-bold">Match and coordinate</h3>
|
||||
<h3 class="mt-4 font-bold">{gettext("Match and coordinate")}</h3>
|
||||
<p class="mt-2 text-sm text-base-content/65">
|
||||
Use private chat and optional live location sharing.
|
||||
{gettext("Use private chat and optional live location sharing.")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-base-200 p-6">
|
||||
<div class="text-3xl">3</div>
|
||||
<h3 class="mt-4 font-bold">Verify the handover</h3>
|
||||
<h3 class="mt-4 font-bold">{gettext("Verify the handover")}</h3>
|
||||
<p class="mt-2 text-sm text-base-content/65">
|
||||
Both confirm and the helper enters a one-time code.
|
||||
{gettext("Both confirm and the helper enters a one-time code.")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,75 +1,76 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<article class="mx-auto max-w-4xl">
|
||||
<div class="text-sm font-semibold text-warning">READ BEFORE USING THE SERVICE</div>
|
||||
<h1 class="mt-2 text-4xl font-black">Safety rules</h1>
|
||||
<div class="text-sm font-semibold text-warning">
|
||||
{gettext("READ BEFORE USING THE SERVICE")}
|
||||
</div>
|
||||
<h1 class="mt-2 text-4xl font-black">{gettext("Safety rules")}</h1>
|
||||
<p class="mt-4 text-base-content/65">
|
||||
Who Need Help coordinates voluntary help between adults. It cannot verify every person,
|
||||
request, item, route, or outcome and cannot guarantee safety.
|
||||
{gettext(
|
||||
"Who Need Help coordinates voluntary help between adults. It cannot verify every person, request, item, route, or outcome and cannot guarantee safety."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div class="mt-8 grid gap-5 md:grid-cols-2">
|
||||
<section class="rounded-3xl border border-error/30 bg-error/5 p-6">
|
||||
<h2 class="text-xl font-bold">Not for emergencies</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Not for emergencies")}</h2>
|
||||
<p class="mt-2 text-sm leading-6">
|
||||
Do not wait for a volunteer when there is immediate danger, a serious medical situation,
|
||||
fire, violence, or a crime in progress. Contact the emergency service for your location.
|
||||
{gettext(
|
||||
"Do not wait for a volunteer when there is immediate danger, a serious medical situation, fire, violence, or a crime in progress. Contact the emergency service for your location."
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-base-300 p-6">
|
||||
<h2 class="text-xl font-bold">Medicine requests</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Medicine requests")}</h2>
|
||||
<p class="mt-2 text-sm leading-6">
|
||||
Only request pickup of a legal item already reserved or paid for. Do not request medical
|
||||
advice, a prescription, controlled substances, cash advances, repackaging, or a purchase
|
||||
on your behalf. Follow pharmacy rules and local law.
|
||||
{gettext(
|
||||
"Only request pickup of a legal item already reserved or paid for. Do not request medical advice, a prescription, controlled substances, cash advances, repackaging, or a purchase on your behalf. Follow pharmacy rules and local law."
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-info/30 bg-info/5 p-6">
|
||||
<h2 class="text-xl font-bold">Social activities</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Social activities")}</h2>
|
||||
<p class="mt-2 text-sm leading-6">
|
||||
Activities are optional plans, not urgent help, and do not affect helper reputation.
|
||||
Meet in a suitable public place first. Organizers approve every participant; exact
|
||||
coordinates and group chat are limited to approved participants. Share route and safety
|
||||
plans for hikes, and never publish home addresses, access codes, or travel documents.
|
||||
{gettext(
|
||||
"Activities are optional plans, not urgent help, and do not affect helper reputation. Meet in a suitable public place first. Organizers approve every participant; exact coordinates and group chat are limited to approved participants. Share route and safety plans for hikes, and never publish home addresses, access codes, or travel documents."
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-base-300 p-6">
|
||||
<h2 class="text-xl font-bold">Free help and thanks</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Free help and thanks")}</h2>
|
||||
<p class="mt-2 text-sm leading-6">
|
||||
Help is voluntary and has no required fee. An external thank-you link is optional and
|
||||
appears only after completion. The platform does not receive, split, refund, guarantee,
|
||||
or report that transfer.
|
||||
{gettext(
|
||||
"Help is voluntary and has no required fee. An external thank-you link is optional and appears only after completion. The platform does not receive, split, refund, guarantee, or report that transfer."
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-base-300 p-6">
|
||||
<h2 class="text-xl font-bold">Location privacy</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Location privacy")}</h2>
|
||||
<p class="mt-2 text-sm leading-6">
|
||||
Public location can be hidden, approximate, or explicitly exact. Live tracking is
|
||||
optional. A browser shares only while its page is open; Android shows a persistent
|
||||
foreground-service notification while sharing. The current exact point is deleted when
|
||||
sharing stops, a match ends, or either participant blocks the other; derived movement and
|
||||
proximity evidence may remain.
|
||||
{gettext(
|
||||
"Public location can be hidden, approximate, or explicitly exact. Live tracking is optional. A browser shares only while its page is open; Android shows a persistent foreground-service notification while sharing. The current exact point is deleted when sharing stops, a match ends, or either participant blocks the other; derived movement and proximity evidence may remain."
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-base-300 p-6">
|
||||
<h2 class="text-xl font-bold">Identity and reputation</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Identity and reputation")}</h2>
|
||||
<p class="mt-2 text-sm leading-6">
|
||||
Email confirmation and social links are not identity guarantees. Manually attached social
|
||||
links are marked unverified. Check the person's history, unique interactions, handover
|
||||
evidence, and revealed reviews, and meet only where you feel safe.
|
||||
{gettext(
|
||||
"Email confirmation and social links are not identity guarantees. Manually attached social links are marked unverified. Check the person's history, unique interactions, handover evidence, and revealed reviews, and meet only where you feel safe."
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-base-300 p-6">
|
||||
<h2 class="text-xl font-bold">Block and report</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Block and report")}</h2>
|
||||
<p class="mt-2 text-sm leading-6">
|
||||
Block a person to hide discovery in both directions and stop new chat. Report dangerous,
|
||||
fraudulent, prohibited, impersonating, harassing, or spam content. Automated signals ask
|
||||
for human review and are not proof by themselves.
|
||||
{gettext(
|
||||
"Block a person to hide discovery in both directions and stop new chat. Report dangerous, fraudulent, prohibited, impersonating, harassing, or spam content. Automated signals ask for human review and are not proof by themselves."
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,14 +21,14 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
|
|||
|> redirect(external: url)
|
||||
else
|
||||
false ->
|
||||
oauth_error(conn, "That verification provider is not configured.")
|
||||
oauth_error(conn, gettext("That verification provider is not configured."))
|
||||
|
||||
{:error, :unsupported_provider} ->
|
||||
oauth_error(conn, "That verification provider is not supported.")
|
||||
oauth_error(conn, gettext("That verification provider is not supported."))
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Unable to start social OAuth (#{error_name(error)})")
|
||||
oauth_error(conn, "Social verification could not be started.")
|
||||
oauth_error(conn, gettext("Social verification could not be started."))
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -48,21 +48,34 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
|
|||
|
||||
conn
|
||||
|> delete_session(session_key(provider))
|
||||
|> put_flash(:info, "GitHub profile verified.")
|
||||
|> put_flash(:info, gettext("GitHub profile verified."))
|
||||
|> redirect(to: ~p"/profile")
|
||||
else
|
||||
nil ->
|
||||
callback_error(conn, provider_param, "Social verification session expired. Please retry.")
|
||||
callback_error(
|
||||
conn,
|
||||
provider_param,
|
||||
gettext("Social verification session expired. Please retry.")
|
||||
)
|
||||
|
||||
false ->
|
||||
callback_error(conn, provider_param, "Social verification session is invalid.")
|
||||
callback_error(
|
||||
conn,
|
||||
provider_param,
|
||||
gettext("Social verification session is invalid.")
|
||||
)
|
||||
|
||||
{:error, :already_linked} ->
|
||||
callback_error(conn, provider_param, "That social account is already linked.")
|
||||
callback_error(conn, provider_param, gettext("That social account is already linked."))
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Social OAuth callback failed (#{error_name(error)})")
|
||||
callback_error(conn, provider_param, "Social verification failed. Please retry.")
|
||||
|
||||
callback_error(
|
||||
conn,
|
||||
provider_param,
|
||||
gettext("Social verification failed. Please retry.")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
|||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"An email was sent to #{user.email}, please access it to confirm your account."
|
||||
gettext(
|
||||
"An email was sent to %{email}, please access it to confirm your account.",
|
||||
email: user.email
|
||||
)
|
||||
)
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|
||||
|
|
@ -37,7 +40,10 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
|||
{:error, :rate_limited} ->
|
||||
conn
|
||||
|> put_status(:too_many_requests)
|
||||
|> put_flash(:error, "Too many registration attempts in the configured time window.")
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Too many registration attempts in the configured time window.")
|
||||
)
|
||||
|> render(:new,
|
||||
changeset: Accounts.change_user_registration(%User{}, user_params)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,22 +2,27 @@
|
|||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Register for an account
|
||||
{gettext("Register for an account")}
|
||||
<:subtitle>
|
||||
Already registered?
|
||||
{gettext("Already registered?")}
|
||||
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
|
||||
Log in
|
||||
{gettext("Log in")}
|
||||
</.link>
|
||||
to your account now.
|
||||
{gettext("to your account now.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<.form :let={f} for={@changeset} action={~p"/users/register"}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={f[:locale].name}
|
||||
value={Gettext.get_locale(WhoNeedHelpWeb.Gettext)}
|
||||
/>
|
||||
<.input
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
label={gettext("Email")}
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
|
|
@ -27,30 +32,32 @@
|
|||
<.input
|
||||
field={f[:display_name]}
|
||||
type="text"
|
||||
label="Display name"
|
||||
label={gettext("Display name")}
|
||||
autocomplete="name"
|
||||
required
|
||||
/>
|
||||
|
||||
<p class="mt-2 text-xs text-base-content/60">
|
||||
Read the <.link href={~p"/safety"} class="link font-semibold">safety rules</.link>
|
||||
before confirming.
|
||||
{gettext("Read the")}
|
||||
<.link href={~p"/safety"} class="link font-semibold">{gettext("safety rules")}</.link>
|
||||
{gettext("before confirming.")}
|
||||
</p>
|
||||
|
||||
<.input
|
||||
field={f[:terms_accepted]}
|
||||
type="checkbox"
|
||||
label="I am 18 or older and accept the safety rules"
|
||||
label={gettext("I am 18 or older and accept the safety rules")}
|
||||
required
|
||||
/>
|
||||
|
||||
<p class="my-4 text-xs text-base-content/60">
|
||||
Who Need Help is not an emergency or medical service. Medicine requests are limited
|
||||
to pickup of legal items already purchased or reserved.
|
||||
{gettext(
|
||||
"Who Need Help is not an emergency or medical service. Medicine requests are limited to pickup of legal items already purchased or reserved."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
|
||||
Create an account
|
||||
<.button phx-disable-with={gettext("Creating account...")} class="btn btn-primary w-full">
|
||||
{gettext("Create an account")}
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
|
||||
info =
|
||||
case params do
|
||||
%{"_action" => "confirmed"} -> "User confirmed successfully."
|
||||
_ -> "Welcome back!"
|
||||
%{"_action" => "confirmed"} -> gettext("User confirmed successfully.")
|
||||
_ -> gettext("Welcome back!")
|
||||
end
|
||||
|
||||
case Accounts.login_user_by_magic_link(token) do
|
||||
|
|
@ -28,7 +28,7 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_flash(:error, "The link is invalid or it has expired.")
|
||||
|> put_flash(:error, gettext("The link is invalid or it has expired."))
|
||||
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
|
||||
end
|
||||
end
|
||||
|
|
@ -37,14 +37,14 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, "Welcome back!")
|
||||
|> put_flash(:info, gettext("Welcome back!"))
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
else
|
||||
form = Phoenix.Component.to_form(user_params, as: "user")
|
||||
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
|> put_flash(:error, "Invalid email or password")
|
||||
|> put_flash(:error, gettext("Invalid email or password"))
|
||||
|> render(:new, form: form)
|
||||
end
|
||||
end
|
||||
|
|
@ -63,7 +63,9 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
end
|
||||
|
||||
info =
|
||||
gettext(
|
||||
"If your email is in our system, you will receive instructions for logging in shortly."
|
||||
)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|
|
@ -72,7 +74,10 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
{:error, :rate_limited} ->
|
||||
conn
|
||||
|> put_status(:too_many_requests)
|
||||
|> put_flash(:error, "Too many sign-in emails in the configured time window.")
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Too many sign-in emails in the configured time window.")
|
||||
)
|
||||
|> render(:new, form: Phoenix.Component.to_form(%{"email" => email}, as: "user"))
|
||||
end
|
||||
end
|
||||
|
|
@ -87,14 +92,14 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
|> render(:confirm)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Magic link is invalid or it has expired.")
|
||||
|> put_flash(:error, gettext("Magic link is invalid or it has expired."))
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> put_flash(:info, gettext("Logged out successfully."))
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>Welcome {@user.email}</.header>
|
||||
<.header>{gettext("Welcome %{email}", email: @user.email)}</.header>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
|
|
@ -15,13 +15,16 @@
|
|||
<.button
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with="Confirming..."
|
||||
phx-disable-with={gettext("Confirming...")}
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
Confirm and stay logged in
|
||||
{gettext("Confirm and stay logged in")}
|
||||
</.button>
|
||||
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
|
||||
Confirm and log in only this time
|
||||
<.button
|
||||
phx-disable-with={gettext("Confirming...")}
|
||||
class="btn btn-primary btn-soft w-full mt-2"
|
||||
>
|
||||
{gettext("Confirm and log in only this time")}
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
|
|
@ -34,26 +37,33 @@
|
|||
>
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<%= if @current_scope do %>
|
||||
<.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
|
||||
Log in
|
||||
<.button
|
||||
variant="primary"
|
||||
phx-disable-with={gettext("Logging in...")}
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
{gettext("Log in")}
|
||||
</.button>
|
||||
<% else %>
|
||||
<.button
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with="Logging in..."
|
||||
phx-disable-with={gettext("Logging in...")}
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
Keep me logged in on this device
|
||||
{gettext("Keep me logged in on this device")}
|
||||
</.button>
|
||||
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
|
||||
Log me in only this time
|
||||
<.button
|
||||
phx-disable-with={gettext("Logging in...")}
|
||||
class="btn btn-primary btn-soft w-full mt-2"
|
||||
>
|
||||
{gettext("Log me in only this time")}
|
||||
</.button>
|
||||
<% end %>
|
||||
</.form>
|
||||
|
||||
<p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
|
||||
Tip: If you prefer passwords, you can enable them in the user settings.
|
||||
{gettext("Tip: If you prefer passwords, you can enable them in the user settings.")}
|
||||
</p>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,18 @@
|
|||
<div class="mx-auto max-w-sm space-y-4">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
<p>Log in</p>
|
||||
<p>{gettext("Log in")}</p>
|
||||
<:subtitle>
|
||||
<%= if @current_scope do %>
|
||||
You need to reauthenticate to perform sensitive actions on your account.
|
||||
{gettext("You need to reauthenticate to perform sensitive actions on your account.")}
|
||||
<% else %>
|
||||
Don't have an account? <.link
|
||||
{gettext("Don't have an account?")}
|
||||
<.link
|
||||
navigate={~p"/users/register"}
|
||||
class="font-semibold text-brand hover:underline"
|
||||
phx-no-format
|
||||
>Sign up</.link> for an account now.
|
||||
>{gettext("Sign up")}</.link>
|
||||
{gettext("for an account now.")}
|
||||
<% end %>
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
|
@ -20,9 +22,10 @@
|
|||
<div :if={local_mail_adapter?()} class="alert alert-info">
|
||||
<.icon name="hero-information-circle" class="size-6 shrink-0" />
|
||||
<div>
|
||||
<p>You are running the local mail adapter.</p>
|
||||
<p>{gettext("You are running the local mail adapter.")}</p>
|
||||
<p>
|
||||
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
|
||||
{gettext("To see sent emails, visit")}
|
||||
<.link href="/dev/mailbox" class="underline">{gettext("the mailbox page")}</.link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -32,25 +35,25 @@
|
|||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
label={gettext("Email")}
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<.button class="btn btn-primary w-full">
|
||||
Log in with email <span aria-hidden="true">→</span>
|
||||
{gettext("Log in with email")} <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<div class="divider">or</div>
|
||||
<div class="divider">{gettext("or")}</div>
|
||||
|
||||
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
label={gettext("Email")}
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
|
|
@ -58,15 +61,15 @@
|
|||
<.input
|
||||
field={f[:password]}
|
||||
type="password"
|
||||
label="Password"
|
||||
label={gettext("Password")}
|
||||
autocomplete="current-password"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
|
||||
Log in and stay logged in <span aria-hidden="true">→</span>
|
||||
{gettext("Log in and stay logged in")} <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
<.button class="btn btn-primary btn-soft w-full mt-2">
|
||||
Log in only this time
|
||||
{gettext("Log in only this time")}
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
|||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"A link to confirm your email change has been sent to the new address."
|
||||
gettext("A link to confirm your email change has been sent to the new address.")
|
||||
)
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
|||
case Accounts.update_user_password(user, user_params) do
|
||||
{:ok, {user, _}} ->
|
||||
conn
|
||||
|> put_flash(:info, "Password updated successfully.")
|
||||
|> put_flash(:info, gettext("Password updated successfully."))
|
||||
|> put_session(:user_return_to, ~p"/users/settings")
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
|
|
@ -57,12 +57,12 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
|||
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
|
||||
{:ok, _user} ->
|
||||
conn
|
||||
|> put_flash(:info, "Email changed successfully.")
|
||||
|> put_flash(:info, gettext("Email changed successfully."))
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, _} ->
|
||||
conn
|
||||
|> put_flash(:error, "Email change link is invalid or it has expired.")
|
||||
|> put_flash(:error, gettext("Email change link is invalid or it has expired."))
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Account Settings
|
||||
<:subtitle>Manage your account email address and password settings</:subtitle>
|
||||
{gettext("Account Settings")}
|
||||
<:subtitle>{gettext("Manage your account email address and password settings")}</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
|
|
@ -12,13 +12,15 @@
|
|||
<.input
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
label={gettext("Email")}
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
|
||||
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
|
||||
<.button variant="primary" phx-disable-with={gettext("Changing...")}>
|
||||
{gettext("Change Email")}
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<div class="divider" />
|
||||
|
|
@ -29,7 +31,7 @@
|
|||
<.input
|
||||
field={f[:password]}
|
||||
type="password"
|
||||
label="New password"
|
||||
label={gettext("New password")}
|
||||
autocomplete="new-password"
|
||||
spellcheck="false"
|
||||
required
|
||||
|
|
@ -37,13 +39,13 @@
|
|||
<.input
|
||||
field={f[:password_confirmation]}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
label={gettext("Confirm new password")}
|
||||
autocomplete="new-password"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<.button variant="primary" phx-disable-with="Changing...">
|
||||
Save Password
|
||||
<.button variant="primary" phx-disable-with={gettext("Changing...")}>
|
||||
{gettext("Save Password")}
|
||||
</.button>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
Activities.list_open_activities(socket.assigns.current_scope, socket.assigns.filters)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Activities")
|
||||
|> assign(:page_title, gettext("Activities"))
|
||||
|> assign(:activities, activities)
|
||||
|> assign(:my_activities, Activities.list_my_activities(socket.assigns.current_scope))
|
||||
|> assign(:categories, Catalog.list_categories(:activity))
|
||||
|
|
@ -52,25 +52,38 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
defp status_label(:open), do: gettext("Open")
|
||||
defp status_label(:completed), do: gettext("Completed")
|
||||
defp status_label(:cancelled), do: gettext("Cancelled")
|
||||
defp status_label(value), do: value
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-info">OPTIONAL SOCIAL PLANS</div>
|
||||
<h1 class="mt-1 text-4xl font-black tracking-tight">Find people to join</h1>
|
||||
<div class="text-sm font-semibold text-info">{gettext("OPTIONAL SOCIAL PLANS")}</div>
|
||||
<h1 class="mt-1 text-4xl font-black tracking-tight">
|
||||
{gettext("Find people to join")}
|
||||
</h1>
|
||||
<p class="mt-2 max-w-2xl text-base-content/65">
|
||||
Coffee, cinema, walks, and hikes are separate from urgent help and never affect helper ratings.
|
||||
{gettext(
|
||||
"Coffee, cinema, walks, and hikes are separate from urgent help and never affect helper ratings."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<.link navigate={~p"/activities/new"} class="btn btn-info">Create activity</.link>
|
||||
<.link navigate={~p"/activities/new"} class="btn btn-info">
|
||||
{gettext("Create activity")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-5 text-sm">
|
||||
<.icon name="hero-shield-check" class="size-5" />
|
||||
<span>
|
||||
Meet in public first, keep exact details inside the approved group, and leave or report anything that feels unsafe.
|
||||
{gettext(
|
||||
"Meet in public first, keep exact details inside the approved group, and leave or report anything that feels unsafe."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -82,12 +95,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
<.input
|
||||
field={@filter_form[:category_id]}
|
||||
type="select"
|
||||
label="Activity type"
|
||||
label={gettext("Activity type")}
|
||||
options={[
|
||||
{"All activities", ""}
|
||||
{gettext("All activities"), ""}
|
||||
| Enum.map(
|
||||
@categories,
|
||||
&{Catalog.category_path(&1, @current_scope.user.locale), &1.id}
|
||||
&{Catalog.category_path(&1, Gettext.get_locale(WhoNeedHelpWeb.Gettext)), &1.id}
|
||||
)
|
||||
]}
|
||||
/>
|
||||
|
|
@ -99,8 +112,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
:if={@activities == []}
|
||||
class="rounded-3xl border border-dashed border-base-300 p-12 text-center"
|
||||
>
|
||||
<h2 class="text-xl font-bold">No open activities yet</h2>
|
||||
<p class="mt-2 text-base-content/60">Create a clear plan in a public place.</p>
|
||||
<h2 class="text-xl font-bold">{gettext("No open activities yet")}</h2>
|
||||
<p class="mt-2 text-base-content/60">
|
||||
{gettext("Create a clear plan in a public place.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.link
|
||||
|
|
@ -112,7 +127,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
<div>
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<span class="badge badge-info badge-outline">
|
||||
{Catalog.category_path(activity.category, @current_scope.user.locale)}
|
||||
{Catalog.category_path(
|
||||
activity.category,
|
||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||
)}
|
||||
</span>
|
||||
<span class="badge badge-outline">
|
||||
{Enum.count(activity.participants, &(&1.status == :approved))}/{activity.capacity}
|
||||
|
|
@ -125,8 +143,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
</div>
|
||||
<div class="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/55">
|
||||
<span>📍 {activity.location_label}</span>
|
||||
<span>🕒 {Calendar.strftime(activity.starts_at, "%d %b, %H:%M UTC")}</span>
|
||||
<span>by {activity.creator.display_name || "Community member"}</span>
|
||||
<span>🕒 {Calendar.strftime(activity.starts_at, "%d.%m.%Y, %H:%M UTC")}</span>
|
||||
<span>
|
||||
{gettext("by %{name}",
|
||||
name: activity.creator.display_name || gettext("Community member")
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</.link>
|
||||
</div>
|
||||
|
|
@ -143,14 +165,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
</div>
|
||||
|
||||
<section :if={@my_activities != []} class="mt-12">
|
||||
<h2 class="text-2xl font-bold">Your activities</h2>
|
||||
<h2 class="text-2xl font-bold">{gettext("Your activities")}</h2>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<.link
|
||||
:for={activity <- @my_activities}
|
||||
navigate={~p"/activities/#{activity.id}"}
|
||||
class="rounded-2xl bg-base-200 p-4"
|
||||
>
|
||||
<span class="badge badge-sm">{activity.status}</span>
|
||||
<span class="badge badge-sm">{status_label(activity.status)}</span>
|
||||
<div class="mt-2 font-semibold">{activity.title}</div>
|
||||
</.link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Create activity")
|
||||
|> assign(:page_title, gettext("Create activity"))
|
||||
|> assign(:categories, Catalog.list_categories(:activity))
|
||||
|> assign(:selected_category, nil)
|
||||
|> assign(:structured_fields, [])
|
||||
|
|
@ -33,7 +33,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
{:ok, activity} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Activity published. You approve every participant.")
|
||||
|> put_flash(:info, gettext("Activity published. You approve every participant."))
|
||||
|> push_navigate(to: ~p"/activities/#{activity.id}")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
|
|
@ -94,10 +94,13 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
end
|
||||
|
||||
defp error_message(:account_not_eligible),
|
||||
do: "Confirm your email and ensure your account is active before publishing."
|
||||
do: gettext("Confirm your email and ensure your account is active before publishing.")
|
||||
|
||||
defp error_message(:rate_limited), do: "Too many actions in the configured time window."
|
||||
defp error_message(reason), do: "Could not publish the activity: #{inspect(reason)}"
|
||||
defp error_message(:rate_limited),
|
||||
do: gettext("Too many actions in the configured time window.")
|
||||
|
||||
defp error_message(reason),
|
||||
do: gettext("Could not publish the activity: %{reason}", reason: inspect(reason))
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
|
|
@ -105,17 +108,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
|
||||
← Back to activities
|
||||
{gettext("← Back to activities")}
|
||||
</.link>
|
||||
<h1 class="text-4xl font-black">Create an activity</h1>
|
||||
<h1 class="text-4xl font-black">{gettext("Create an activity")}</h1>
|
||||
<p class="mt-2 text-base-content/65">
|
||||
Publish a clear public plan. Join requests stay pending until you approve them.
|
||||
{gettext("Publish a clear public plan. Join requests stay pending until you approve them.")}
|
||||
</p>
|
||||
|
||||
<div class="alert alert-warning mt-6 text-sm">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<span>
|
||||
Use a public first meeting point. Do not publish a home address, access code, travel document, or other sensitive information.
|
||||
{gettext(
|
||||
"Use a public first meeting point. Do not publish a home address, access code, travel document, or other sensitive information."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -129,12 +134,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
<.input
|
||||
field={@form[:category_id]}
|
||||
type="select"
|
||||
label="Activity type"
|
||||
prompt="Choose activity"
|
||||
label={gettext("Activity type")}
|
||||
prompt={gettext("Choose activity")}
|
||||
options={
|
||||
Enum.map(
|
||||
@categories,
|
||||
&{Catalog.category_path(&1, @current_scope.user.locale), &1.id}
|
||||
&{Catalog.category_path(&1, Gettext.get_locale(WhoNeedHelpWeb.Gettext)), &1.id}
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
|
@ -143,24 +148,29 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
id="selected-activity-category-description"
|
||||
class="-mt-3 text-sm text-base-content/60"
|
||||
>
|
||||
{@selected_category.description}
|
||||
{Catalog.Category.description(
|
||||
@selected_category,
|
||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
:if={@structured_fields != []}
|
||||
class="space-y-4 rounded-2xl border border-info/30 bg-info/5 p-5"
|
||||
>
|
||||
<h2 class="font-bold">Activity details</h2>
|
||||
<h2 class="font-bold">{gettext("Activity details")}</h2>
|
||||
<div :for={field <- @structured_fields}>
|
||||
<label class="fieldset">
|
||||
<span class="label">{field_label(field, @current_scope.user.locale)}</span>
|
||||
<span class="label">
|
||||
{field_label(field, Gettext.get_locale(WhoNeedHelpWeb.Gettext))}
|
||||
</span>
|
||||
<select
|
||||
:if={field["type"] == "select"}
|
||||
name={"activity[structured_data][#{field["key"]}]"}
|
||||
required={field["required"] == true}
|
||||
class="select w-full"
|
||||
>
|
||||
<option value="">Choose…</option>
|
||||
<option value="">{gettext("Choose…")}</option>
|
||||
<option
|
||||
:for={option <- field["options"] || []}
|
||||
value={option_value(option)}
|
||||
|
|
@ -169,7 +179,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
to_string(option_value(option))
|
||||
}
|
||||
>
|
||||
{option_label(option, @current_scope.user.locale)}
|
||||
{option_label(option, Gettext.get_locale(WhoNeedHelpWeb.Gettext))}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
|
|
@ -187,12 +197,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
required={field["required"] == true}
|
||||
class="select w-full"
|
||||
>
|
||||
<option value="">Choose…</option>
|
||||
<option value="">{gettext("Choose…")}</option>
|
||||
<option value="true" selected={structured_value(@form, field["key"]) == "true"}>
|
||||
Yes
|
||||
{gettext("Yes")}
|
||||
</option>
|
||||
<option value="false" selected={structured_value(@form, field["key"]) == "false"}>
|
||||
No
|
||||
{gettext("No")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
|
@ -202,34 +212,49 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<.input field={@form[:title]} label="Short title" placeholder="Coffee and conversation" />
|
||||
<.input
|
||||
field={@form[:title]}
|
||||
label={gettext("Short title")}
|
||||
placeholder={gettext("Coffee and conversation")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:description]}
|
||||
type="textarea"
|
||||
label="Plan and expectations"
|
||||
placeholder="Describe the public plan, approximate duration, and who might enjoy it."
|
||||
label={gettext("Plan and expectations")}
|
||||
placeholder={
|
||||
gettext("Describe the public plan, approximate duration, and who might enjoy it.")
|
||||
}
|
||||
/>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<.input field={@form[:starts_at]} type="datetime-local" label="Starts at (UTC)" />
|
||||
<.input
|
||||
field={@form[:starts_at]}
|
||||
type="datetime-local"
|
||||
label={gettext("Starts at (UTC)")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:join_deadline]}
|
||||
type="datetime-local"
|
||||
label="Join requests close (UTC)"
|
||||
label={gettext("Join requests close (UTC)")}
|
||||
/>
|
||||
</div>
|
||||
<.input field={@form[:capacity]} type="number" min="2" label="Total group capacity" />
|
||||
<.input
|
||||
field={@form[:capacity]}
|
||||
type="number"
|
||||
min="2"
|
||||
label={gettext("Total group capacity")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:location_label]}
|
||||
label="Approximate public meeting area"
|
||||
placeholder="City centre, near the main entrance"
|
||||
label={gettext("Approximate public meeting area")}
|
||||
placeholder={gettext("City centre, near the main entrance")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:location_visibility]}
|
||||
type="select"
|
||||
label="Public map visibility"
|
||||
label={gettext("Public map visibility")}
|
||||
options={[
|
||||
{"Approximate area", "approximate_public"},
|
||||
{"Hidden", "hidden"}
|
||||
{gettext("Approximate area"), "approximate_public"},
|
||||
{gettext("Hidden"), "hidden"}
|
||||
]}
|
||||
/>
|
||||
<div class="rounded-2xl bg-base-200 p-5">
|
||||
|
|
@ -239,10 +264,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
id="activity-location-picker"
|
||||
class="btn btn-outline btn-sm"
|
||||
>
|
||||
Use my current foreground location
|
||||
{gettext("Use my current foreground location")}
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-base-content/60">
|
||||
Exact coordinates are shown only to participants you approve.
|
||||
{gettext("Exact coordinates are shown only to participants you approve.")}
|
||||
</p>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<.input
|
||||
|
|
@ -250,25 +275,30 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
id="activity-latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
label="Latitude"
|
||||
label={gettext("Latitude")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:longitude]}
|
||||
id="activity-longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
label="Longitude"
|
||||
label={gettext("Longitude")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-start gap-3 rounded-2xl border border-base-300 p-4 text-sm">
|
||||
<input type="checkbox" required class="checkbox checkbox-info mt-0.5" />
|
||||
<span>
|
||||
I will approve participants individually, keep sensitive details in the approved group, and use an appropriate public first meeting point.
|
||||
{gettext(
|
||||
"I will approve participants individually, keep sensitive details in the approved group, and use an appropriate public first meeting point."
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<.button class="btn btn-info btn-lg w-full" phx-disable-with="Publishing…">
|
||||
Publish activity
|
||||
<.button
|
||||
class="btn btn-info btn-lg w-full"
|
||||
phx-disable-with={gettext("Publishing…")}
|
||||
>
|
||||
{gettext("Publish activity")}
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
{:error, _reason} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Activity is unavailable.")
|
||||
|> put_flash(:error, gettext("Activity is unavailable."))
|
||||
|> push_navigate(to: ~p"/activities")}
|
||||
end
|
||||
end
|
||||
|
|
@ -96,7 +96,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
socket
|
||||
|> assign(:report_form, report_form())
|
||||
|> assign(:report_message_id, nil)
|
||||
|> put_flash(:info, "Report sent to moderators.")}
|
||||
|> put_flash(:info, gettext("Report sent to moderators."))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, error_message(reason))}
|
||||
|
|
@ -112,7 +112,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
if message do
|
||||
{:noreply, assign(socket, :report_message_id, message.id)}
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, "That message cannot be reported from this view.")}
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("That message cannot be reported from this view."))}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -125,7 +126,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
{:ok, _block} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Organizer blocked. Their activities and messages are hidden.")
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext("Organizer blocked. Their activities and messages are hidden.")
|
||||
)
|
||||
|> push_navigate(to: ~p"/activities")}
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
@ -146,7 +150,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
|
||||
{:error, _} ->
|
||||
socket
|
||||
|> put_flash(:error, "Activity is unavailable.")
|
||||
|> put_flash(:error, gettext("Activity is unavailable."))
|
||||
|> push_navigate(to: ~p"/activities")
|
||||
end
|
||||
end
|
||||
|
|
@ -180,6 +184,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
|> assign(:organizer?, organizer?)
|
||||
|> assign(:approved?, approved?)
|
||||
|> assign(:coordinates, coordinates)
|
||||
|> assign(
|
||||
:structured_details,
|
||||
structured_details(activity, Gettext.get_locale(WhoNeedHelpWeb.Gettext))
|
||||
)
|
||||
|> assign(:markers, Jason.encode!(markers))
|
||||
|> assign(:message_form, to_form(%{"body" => ""}, as: :activity_message))
|
||||
|> assign(:report_message_id, nil)
|
||||
|
|
@ -202,28 +210,87 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
|
||||
defp ordered_messages(activity), do: Enum.sort_by(activity.messages, & &1.inserted_at, DateTime)
|
||||
|
||||
defp structured_details(activity, locale) do
|
||||
activity.category
|
||||
|> WhoNeedHelp.Catalog.structured_fields()
|
||||
|> Enum.flat_map(fn field ->
|
||||
case activity.structured_data[field["key"]] do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
value ->
|
||||
label = localized(field["label"], locale) || field["key"]
|
||||
|
||||
[{label, structured_value(field, value, locale)}]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp structured_value(%{"type" => "select"} = field, value, locale) do
|
||||
option =
|
||||
Enum.find(field["options"] || [], fn
|
||||
%{"value" => candidate} -> to_string(candidate) == to_string(value)
|
||||
candidate -> to_string(candidate) == to_string(value)
|
||||
end)
|
||||
|
||||
case option do
|
||||
%{"label" => labels} -> localized(labels, locale) || to_string(value)
|
||||
nil -> to_string(value)
|
||||
candidate -> to_string(candidate)
|
||||
end
|
||||
end
|
||||
|
||||
defp structured_value(%{"type" => "boolean"}, value, _locale)
|
||||
when value in [true, "true", "1"],
|
||||
do: gettext("Yes")
|
||||
|
||||
defp structured_value(%{"type" => "boolean"}, _value, _locale), do: gettext("No")
|
||||
defp structured_value(_field, value, _locale), do: to_string(value)
|
||||
|
||||
defp localized(labels, locale) when is_map(labels),
|
||||
do: labels[to_string(locale)] || labels["en"]
|
||||
|
||||
defp localized(_labels, _locale), do: nil
|
||||
|
||||
defp report_form do
|
||||
to_form(%{"reason" => "harassment", "details" => ""}, as: :report)
|
||||
end
|
||||
|
||||
defp error_message(:already_joined), do: "You already have a join request for this activity."
|
||||
defp error_message(:capacity_reached), do: "The approved group has reached its capacity."
|
||||
defp error_message(:join_closed), do: "Join requests are closed."
|
||||
defp error_message(:blocked), do: "This action is unavailable."
|
||||
defp error_message(:cannot_report_self), do: "You cannot report your own content."
|
||||
defp error_message(:forbidden), do: "You are not allowed to do that."
|
||||
defp error_message(:invalid_transition), do: "This activity state has already changed."
|
||||
defp error_message(:not_open), do: "This activity is no longer open."
|
||||
defp error_message(:rate_limited), do: "Too many actions in the configured time window."
|
||||
defp error_message(%Ecto.Changeset{}), do: "Please check the submitted message."
|
||||
defp error_message(reason), do: "Could not complete the action: #{inspect(reason)}"
|
||||
defp error_message(:already_joined),
|
||||
do: gettext("You already have a join request for this activity.")
|
||||
|
||||
defp error_message(:capacity_reached),
|
||||
do: gettext("The approved group has reached its capacity.")
|
||||
|
||||
defp error_message(:join_closed), do: gettext("Join requests are closed.")
|
||||
defp error_message(:blocked), do: gettext("This action is unavailable.")
|
||||
defp error_message(:cannot_report_self), do: gettext("You cannot report your own content.")
|
||||
defp error_message(:forbidden), do: gettext("You are not allowed to do that.")
|
||||
|
||||
defp error_message(:invalid_transition),
|
||||
do: gettext("This activity state has already changed.")
|
||||
|
||||
defp error_message(:not_open), do: gettext("This activity is no longer open.")
|
||||
|
||||
defp error_message(:rate_limited),
|
||||
do: gettext("Too many actions in the configured time window.")
|
||||
|
||||
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted message.")
|
||||
|
||||
defp error_message(reason),
|
||||
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
|
||||
|
||||
defp status_label(:open), do: gettext("Open")
|
||||
defp status_label(:completed), do: gettext("Completed")
|
||||
defp status_label(:cancelled), do: gettext("Cancelled")
|
||||
defp status_label(value), do: value
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
|
||||
← Back to activities
|
||||
{gettext("← Back to activities")}
|
||||
</.link>
|
||||
|
||||
<div class="grid gap-7 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
|
|
@ -233,12 +300,15 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
<span class="badge badge-info badge-outline">
|
||||
{WhoNeedHelp.Catalog.category_path(
|
||||
@activity.category,
|
||||
@current_scope.user.locale
|
||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||
)}
|
||||
</span>
|
||||
<span class="badge">{@activity.status}</span>
|
||||
<span class="badge">{status_label(@activity.status)}</span>
|
||||
<span class="badge badge-outline">
|
||||
{length(approved_participants(@activity))}/{@activity.capacity} approved
|
||||
{gettext("%{approved}/%{capacity} approved",
|
||||
approved: length(approved_participants(@activity)),
|
||||
capacity: @activity.capacity
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="mt-4 text-4xl font-black">{@activity.title}</h1>
|
||||
|
|
@ -246,30 +316,32 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
|
||||
<dl class="mt-6 grid gap-4 rounded-2xl bg-base-200 p-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/50">Starts</dt>
|
||||
<dt class="text-xs text-base-content/50">{gettext("Starts")}</dt>
|
||||
<dd class="font-semibold">
|
||||
{Calendar.strftime(@activity.starts_at, "%d %b %Y, %H:%M UTC")}
|
||||
{Calendar.strftime(@activity.starts_at, "%d.%m.%Y, %H:%M UTC")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/50">Join requests close</dt>
|
||||
<dt class="text-xs text-base-content/50">{gettext("Join requests close")}</dt>
|
||||
<dd class="font-semibold">
|
||||
{Calendar.strftime(@activity.join_deadline, "%d %b %Y, %H:%M UTC")}
|
||||
{Calendar.strftime(@activity.join_deadline, "%d.%m.%Y, %H:%M UTC")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/50">Meeting area</dt>
|
||||
<dt class="text-xs text-base-content/50">{gettext("Meeting area")}</dt>
|
||||
<dd class="font-semibold">{@activity.location_label}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/50">Organizer</dt>
|
||||
<dd class="font-semibold">{@activity.creator.display_name || "Community member"}</dd>
|
||||
<dt class="text-xs text-base-content/50">{gettext("Organizer")}</dt>
|
||||
<dd class="font-semibold">
|
||||
{@activity.creator.display_name || gettext("Community member")}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
:if={@activity.creator.social_identities != []}
|
||||
class="sm:col-span-2"
|
||||
>
|
||||
<dt class="text-xs text-base-content/50">Organizer social links</dt>
|
||||
<dt class="text-xs text-base-content/50">{gettext("Organizer social links")}</dt>
|
||||
<dd class="mt-2 flex flex-wrap gap-2">
|
||||
<a
|
||||
:for={identity <- @activity.creator.social_identities}
|
||||
|
|
@ -283,35 +355,37 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
]}
|
||||
>
|
||||
{identity.provider} · {if identity.verified_at,
|
||||
do: "verified",
|
||||
else: "unverified"}
|
||||
do: gettext("verified"),
|
||||
else: gettext("unverified")}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div :if={map_size(@activity.structured_data) > 0} class="mt-5">
|
||||
<h2 class="font-bold">Plan details</h2>
|
||||
<div :if={@structured_details != []} class="mt-5">
|
||||
<h2 class="font-bold">{gettext("Plan details")}</h2>
|
||||
<dl class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div
|
||||
:for={{key, value} <- @activity.structured_data}
|
||||
:for={{label, value} <- @structured_details}
|
||||
class="rounded-xl border border-base-300 p-3"
|
||||
>
|
||||
<dt class="text-xs text-base-content/50">{String.replace(key, "_", " ")}</dt>
|
||||
<dd class="font-medium">{to_string(value)}</dd>
|
||||
<dt class="text-xs text-base-content/50">{label}</dt>
|
||||
<dd class="font-medium">{value}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :if={@approved?} class="rounded-3xl border border-info/30 bg-info/5 p-6">
|
||||
<h2 class="text-xl font-bold">Approved group chat</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Approved group chat")}</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
Only the organizer and approved participants can read or send these messages.
|
||||
{gettext(
|
||||
"Only the organizer and approved participants can read or send these messages."
|
||||
)}
|
||||
</p>
|
||||
<div id="activity-messages" class="mt-5 max-h-96 space-y-3 overflow-y-auto">
|
||||
<p :if={@activity.messages == []} class="text-sm text-base-content/50">
|
||||
No messages yet.
|
||||
{gettext("No messages yet.")}
|
||||
</p>
|
||||
<article
|
||||
:for={message <- ordered_messages(@activity)}
|
||||
|
|
@ -319,7 +393,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-xs font-semibold text-info">
|
||||
{message.sender.display_name || "Participant"}
|
||||
{message.sender.display_name || gettext("Participant")}
|
||||
</div>
|
||||
<button
|
||||
:if={message.sender_id != @current_scope.user.id}
|
||||
|
|
@ -328,7 +402,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
phx-value-id={message.id}
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Report
|
||||
{gettext("Report")}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm">{message.body}</p>
|
||||
|
|
@ -343,10 +417,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
>
|
||||
<.input
|
||||
field={@message_form[:body]}
|
||||
placeholder="Message the approved group"
|
||||
placeholder={gettext("Message the approved group")}
|
||||
class="flex-1"
|
||||
/>
|
||||
<.button class="btn btn-info self-end">Send</.button>
|
||||
<.button class="btn btn-info self-end">{gettext("Send")}</.button>
|
||||
</.form>
|
||||
</section>
|
||||
</main>
|
||||
|
|
@ -360,12 +434,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
class="aid-map"
|
||||
/>
|
||||
<div class="rounded-2xl bg-base-200 p-5 text-sm">
|
||||
<h2 class="font-bold">Location privacy</h2>
|
||||
<h2 class="font-bold">{gettext("Location privacy")}</h2>
|
||||
<p class="mt-2 text-base-content/65">
|
||||
<%= if @approved? do %>
|
||||
You are approved, so the map can show the exact meeting coordinates.
|
||||
{gettext("You are approved, so the map can show the exact meeting coordinates.")}
|
||||
<% else %>
|
||||
The public map is approximate or hidden. Exact coordinates appear only after approval.
|
||||
{gettext(
|
||||
"The public map is approximate or hidden. Exact coordinates appear only after approval."
|
||||
)}
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -376,12 +452,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
phx-click="join"
|
||||
class="btn btn-info w-full"
|
||||
>
|
||||
Request to join
|
||||
{gettext("Request to join")}
|
||||
</button>
|
||||
<div :if={@viewer_participant && @viewer_participant.status == :requested}>
|
||||
<span class="badge badge-warning">Approval pending</span>
|
||||
<span class="badge badge-warning">{gettext("Approval pending")}</span>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
The organizer has not approved your request yet.
|
||||
{gettext("The organizer has not approved your request yet.")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -389,18 +465,20 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
phx-click="leave"
|
||||
class="btn btn-outline btn-error w-full"
|
||||
>
|
||||
Leave activity
|
||||
{gettext("Leave activity")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section :if={@organizer?} class="rounded-2xl border border-info/30 p-5">
|
||||
<h2 class="font-bold">Organizer controls</h2>
|
||||
<h2 class="font-bold">{gettext("Organizer controls")}</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<article
|
||||
:for={participant <- pending_participants(@activity)}
|
||||
class="rounded-xl bg-base-200 p-3"
|
||||
>
|
||||
<div class="font-semibold">{participant.user.display_name || "Community member"}</div>
|
||||
<div class="font-semibold">
|
||||
{participant.user.display_name || gettext("Community member")}
|
||||
</div>
|
||||
<div
|
||||
:if={participant.user.social_identities != []}
|
||||
class="mt-2 flex flex-wrap gap-1"
|
||||
|
|
@ -417,8 +495,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
]}
|
||||
>
|
||||
{identity.provider} · {if identity.verified_at,
|
||||
do: "verified",
|
||||
else: "unverified"}
|
||||
do: gettext("verified"),
|
||||
else: gettext("unverified")}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
|
|
@ -427,38 +505,38 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
phx-value-id={participant.id}
|
||||
class="btn btn-info btn-xs"
|
||||
>
|
||||
Approve
|
||||
{gettext("Approve")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="decline"
|
||||
phx-value-id={participant.id}
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Decline
|
||||
{gettext("Decline")}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<p :if={pending_participants(@activity) == []} class="text-sm text-base-content/50">
|
||||
No pending join requests.
|
||||
{gettext("No pending join requests.")}
|
||||
</p>
|
||||
</div>
|
||||
<div :if={@activity.status == :open} class="mt-5 grid gap-2">
|
||||
<button phx-click="complete" class="btn btn-success btn-sm">
|
||||
Mark completed
|
||||
{gettext("Mark completed")}
|
||||
</button>
|
||||
<button phx-click="cancel" class="btn btn-outline btn-error btn-sm">
|
||||
Cancel activity
|
||||
{gettext("Cancel activity")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-base-200 p-5">
|
||||
<h2 class="font-bold">Approved participants</h2>
|
||||
<h2 class="font-bold">{gettext("Approved participants")}</h2>
|
||||
<ul class="mt-3 space-y-2 text-sm">
|
||||
<li :for={participant <- approved_participants(@activity)}>
|
||||
{participant.user.display_name || "Community member"}
|
||||
{participant.user.display_name || gettext("Community member")}
|
||||
<span :if={participant.role == :organizer} class="badge badge-xs ml-1">
|
||||
organizer
|
||||
{gettext("organizer")}
|
||||
</span>
|
||||
<div
|
||||
:if={participant.user.social_identities != []}
|
||||
|
|
@ -476,8 +554,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
]}
|
||||
>
|
||||
{identity.provider} · {if identity.verified_at,
|
||||
do: "verified",
|
||||
else: "unverified"}
|
||||
do: gettext("verified"),
|
||||
else: gettext("unverified")}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -488,43 +566,49 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
:if={not @organizer? or @report_message_id}
|
||||
class="rounded-2xl border border-error/30 p-5"
|
||||
>
|
||||
<h2 class="font-bold">Safety controls</h2>
|
||||
<h2 class="font-bold">{gettext("Safety controls")}</h2>
|
||||
<button
|
||||
:if={not @organizer?}
|
||||
phx-click="block-organizer"
|
||||
class="btn btn-outline btn-error btn-sm mt-3 w-full"
|
||||
>
|
||||
Block organizer
|
||||
{gettext("Block organizer")}
|
||||
</button>
|
||||
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2">
|
||||
<div
|
||||
:if={@report_message_id}
|
||||
class="flex items-center justify-between rounded-xl bg-warning/10 p-3 text-xs"
|
||||
>
|
||||
<span>The report is scoped to the selected group message.</span>
|
||||
<span>{gettext("The report is scoped to the selected group message.")}</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="clear-report-message"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Clear
|
||||
{gettext("Clear")}
|
||||
</button>
|
||||
</div>
|
||||
<.input
|
||||
field={@report_form[:reason]}
|
||||
type="select"
|
||||
label="Reason"
|
||||
label={gettext("Reason")}
|
||||
options={[
|
||||
{"Harassment", "harassment"},
|
||||
{"Dangerous plan", "dangerous_request"},
|
||||
{"Fraud", "fraud"},
|
||||
{"Spam", "spam"},
|
||||
{"Impersonation", "impersonation"},
|
||||
{"Other", "other"}
|
||||
{gettext("Harassment"), "harassment"},
|
||||
{gettext("Dangerous plan"), "dangerous_request"},
|
||||
{gettext("Fraud"), "fraud"},
|
||||
{gettext("Spam"), "spam"},
|
||||
{gettext("Impersonation"), "impersonation"},
|
||||
{gettext("Other"), "other"}
|
||||
]}
|
||||
/>
|
||||
<.input field={@report_form[:details]} type="textarea" label="What happened?" />
|
||||
<.button class="btn btn-error btn-outline w-full">Send report</.button>
|
||||
<.input
|
||||
field={@report_form[:details]}
|
||||
type="textarea"
|
||||
label={gettext("What happened?")}
|
||||
/>
|
||||
<.button class="btn btn-error btn-outline w-full">
|
||||
{gettext("Send report")}
|
||||
</.button>
|
||||
</.form>
|
||||
</section>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
|||
case Catalog.propose(socket.assigns.current_scope, params) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket |> put_flash(:info, "Proposal published for community voting.") |> load()}
|
||||
socket
|
||||
|> put_flash(:info, gettext("Proposal published for community voting."))
|
||||
|> load()}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
|
|
@ -32,16 +34,20 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
|||
end
|
||||
|
||||
defp action_error(:account_not_eligible),
|
||||
do: "Confirm your account and ensure it is active first."
|
||||
do: gettext("Confirm your account and ensure it is active first.")
|
||||
|
||||
defp action_error(:rate_limited), do: "Too many actions in the configured time window."
|
||||
defp action_error(:proposal_closed), do: "This proposal is already closed."
|
||||
defp action_error(%Ecto.Changeset{}), do: "You already voted for this proposal."
|
||||
defp action_error(reason), do: "Could not complete the action: #{inspect(reason)}"
|
||||
defp action_error(:rate_limited),
|
||||
do: gettext("Too many actions in the configured time window.")
|
||||
|
||||
defp action_error(:proposal_closed), do: gettext("This proposal is already closed.")
|
||||
defp action_error(%Ecto.Changeset{}), do: gettext("You already voted for this proposal.")
|
||||
|
||||
defp action_error(reason),
|
||||
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
|
||||
|
||||
defp load(socket) do
|
||||
socket
|
||||
|> assign(:page_title, "Category proposals")
|
||||
|> assign(:page_title, gettext("Category proposals"))
|
||||
|> assign(:proposals, Catalog.list_proposals())
|
||||
|> assign(:categories, Catalog.list_all_categories())
|
||||
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{})))
|
||||
|
|
@ -53,56 +59,76 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="grid gap-8 lg:grid-cols-[.8fr_1.2fr]">
|
||||
<section>
|
||||
<div class="text-sm font-semibold text-success">COMMUNITY-DRIVEN TAXONOMY</div>
|
||||
<h1 class="mt-2 text-4xl font-black">What help category is missing?</h1>
|
||||
<div class="text-sm font-semibold text-success">
|
||||
{gettext("COMMUNITY-DRIVEN TAXONOMY")}
|
||||
</div>
|
||||
<h1 class="mt-2 text-4xl font-black">
|
||||
{gettext("What help category is missing?")}
|
||||
</h1>
|
||||
<p class="mt-3 text-base-content/65">
|
||||
Proposals and votes guide moderators. Approval remains a human decision.
|
||||
{gettext("Proposals and votes guide moderators. Approval remains a human decision.")}
|
||||
</p>
|
||||
<.form for={@form} phx-submit="propose" class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6">
|
||||
<.input
|
||||
field={@form[:proposed_name]}
|
||||
label="Proposed category"
|
||||
placeholder="Bicycle puncture"
|
||||
label={gettext("Proposed category")}
|
||||
placeholder={gettext("Bicycle puncture")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:mode]}
|
||||
type="select"
|
||||
label="Mode"
|
||||
options={[{"Urgent help", "help"}, {"Social activity", "activity"}]}
|
||||
label={gettext("Mode")}
|
||||
options={[
|
||||
{gettext("Urgent help"), "help"},
|
||||
{gettext("Social activity"), "activity"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:parent_id]}
|
||||
type="select"
|
||||
label="Parent (optional)"
|
||||
prompt="Top level"
|
||||
label={gettext("Parent (optional)")}
|
||||
prompt={gettext("Top level")}
|
||||
options={
|
||||
Enum.map(
|
||||
@categories,
|
||||
&{WhoNeedHelp.Catalog.Category.name(&1, @current_scope.user.locale), &1.id}
|
||||
&{WhoNeedHelp.Catalog.Category.name(
|
||||
&1,
|
||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||
), &1.id}
|
||||
)
|
||||
}
|
||||
/>
|
||||
<.input field={@form[:reason]} type="textarea" label="Why is this useful?" />
|
||||
<.button class="btn btn-primary w-full">Publish proposal</.button>
|
||||
<.input
|
||||
field={@form[:reason]}
|
||||
type="textarea"
|
||||
label={gettext("Why is this useful?")}
|
||||
/>
|
||||
<.button class="btn btn-primary w-full">{gettext("Publish proposal")}</.button>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold">Open proposals</h2>
|
||||
<h2 class="text-2xl font-bold">{gettext("Open proposals")}</h2>
|
||||
<div class="mt-5 space-y-3">
|
||||
<div
|
||||
:if={@proposals == []}
|
||||
class="rounded-2xl border border-dashed p-8 text-center text-base-content/55"
|
||||
>
|
||||
No proposals yet.
|
||||
{gettext("No proposals yet.")}
|
||||
</div>
|
||||
<article :for={proposal <- @proposals} class="rounded-2xl border border-base-300 p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold">{proposal.proposed_name}</h3>
|
||||
<span class="badge badge-sm badge-outline">{proposal.mode}</span>
|
||||
<span class="badge badge-sm badge-outline">
|
||||
{if proposal.mode == :help,
|
||||
do: gettext("Urgent help"),
|
||||
else: gettext("Social activity")}
|
||||
</span>
|
||||
<p class="mt-2 text-sm text-base-content/65">{proposal.reason}</p>
|
||||
<p class="mt-3 text-xs text-base-content/45">by {proposal.proposer.display_name}</p>
|
||||
<p class="mt-3 text-xs text-base-content/45">
|
||||
{gettext("by %{name}", name: proposal.proposer.display_name)}
|
||||
</p>
|
||||
</div>
|
||||
<button phx-click="vote" phx-value-id={proposal.id} class="btn btn-outline btn-sm">
|
||||
▲ {length(proposal.votes)}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
|
|||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Community helpers")
|
||||
|> assign(:page_title, gettext("Community helpers"))
|
||||
|> assign(:leaders, Trust.leaderboard())}
|
||||
end
|
||||
|
||||
|
|
@ -15,33 +15,37 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm">← Requests</.link>
|
||||
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm">
|
||||
{gettext("← Requests")}
|
||||
</.link>
|
||||
<div class="mt-5 max-w-4xl">
|
||||
<div class="text-sm font-semibold text-success">COMMUNITY TRUST</div>
|
||||
<h1 class="mt-1 text-4xl font-black tracking-tight">Helpers leaderboard</h1>
|
||||
<div class="text-sm font-semibold text-success">{gettext("COMMUNITY TRUST")}</div>
|
||||
<h1 class="mt-1 text-4xl font-black tracking-tight">
|
||||
{gettext("Helpers leaderboard")}
|
||||
</h1>
|
||||
<p class="mt-3 text-base-content/65">
|
||||
Ranking prioritizes unique people helped with optional location-supported evidence,
|
||||
then unique verified handovers. Repeated help between the same pair does not inflate
|
||||
the primary score.
|
||||
{gettext(
|
||||
"Ranking prioritizes unique people helped with optional location-supported evidence, then unique verified handovers. Repeated help between the same pair does not inflate the primary score."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div class="mt-7 overflow-x-auto rounded-3xl border border-base-300">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Helper</th>
|
||||
<th>Location-supported people</th>
|
||||
<th>Verified people</th>
|
||||
<th>Unique people</th>
|
||||
<th>Total</th>
|
||||
<th>Rating</th>
|
||||
<th>{gettext("Rank")}</th>
|
||||
<th>{gettext("Helper")}</th>
|
||||
<th>{gettext("Location-supported people")}</th>
|
||||
<th>{gettext("Verified people")}</th>
|
||||
<th>{gettext("Unique people")}</th>
|
||||
<th>{gettext("Total")}</th>
|
||||
<th>{gettext("Rating")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :if={@leaders == []}>
|
||||
<td colspan="7" class="py-10 text-center text-base-content/55">
|
||||
No completed help yet.
|
||||
{gettext("No completed help yet.")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr :for={{entry, index} <- Enum.with_index(@leaders, 1)}>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket |> assign(:page_title, "Moderation") |> assign(:evidence, nil) |> load()}
|
||||
{:ok,
|
||||
socket |> assign(:page_title, gettext("Moderation")) |> assign(:evidence, nil) |> load()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -13,7 +14,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Trust.moderate_report(socket.assigns.current_scope, id, params),
|
||||
"Report updated."
|
||||
gettext("Report updated.")
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Trust.moderate_signal(socket.assigns.current_scope, id, params),
|
||||
"Signal updated."
|
||||
gettext("Signal updated.")
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Trust.moderate_user(socket.assigns.current_scope, id, params),
|
||||
"User status updated."
|
||||
gettext("User status updated.")
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Trust.moderate_role(socket.assigns.current_scope, id, params),
|
||||
"User role updated."
|
||||
gettext("User role updated.")
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Trust.hide_request(socket.assigns.current_scope, id, note),
|
||||
"Request hidden."
|
||||
gettext("Request hidden.")
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Trust.restore_request(socket.assigns.current_scope, id),
|
||||
"Request restored."
|
||||
gettext("Request restored.")
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Trust.hide_activity(socket.assigns.current_scope, id, note),
|
||||
"Activity hidden."
|
||||
gettext("Activity hidden.")
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Trust.restore_activity(socket.assigns.current_scope, id),
|
||||
"Activity restored."
|
||||
gettext("Activity restored.")
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -91,12 +92,20 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
}
|
||||
|> Map.reject(fn {_locale, name} -> name in [nil, ""] end)
|
||||
|
||||
descriptions =
|
||||
%{
|
||||
"en" => params["description_en"],
|
||||
"uk" => params["description_uk"],
|
||||
"ru" => params["description_ru"]
|
||||
}
|
||||
|> Map.reject(fn {_locale, description} -> description in [nil, ""] end)
|
||||
|
||||
with {:ok, structured_fields} <- Jason.decode(params["structured_fields_json"] || "{}"),
|
||||
true <- is_map(structured_fields) do
|
||||
attrs = %{
|
||||
"slug" => params["slug"],
|
||||
"names" => names,
|
||||
"description" => params["description"],
|
||||
"descriptions" => descriptions,
|
||||
"structured_fields" => structured_fields,
|
||||
"moderation_note" => params["moderation_note"]
|
||||
}
|
||||
|
|
@ -104,11 +113,16 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Catalog.approve_proposal(socket.assigns.current_scope, id, attrs),
|
||||
"Category created and proposal approved."
|
||||
gettext("Category created and proposal approved.")
|
||||
)
|
||||
else
|
||||
_ ->
|
||||
{:noreply, put_flash(socket, :error, "Structured fields must be a valid JSON object.")}
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Structured fields must be a valid JSON object.")
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -116,7 +130,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Catalog.reject_proposal(socket.assigns.current_scope, id, note),
|
||||
"Proposal rejected."
|
||||
gettext("Proposal rejected.")
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -128,7 +142,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
respond(
|
||||
socket,
|
||||
Catalog.merge_proposal(socket.assigns.current_scope, id, category_id, note),
|
||||
"Proposal merged."
|
||||
gettext("Proposal merged.")
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -149,29 +163,74 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
|> assign(:categories, Catalog.list_all_categories())
|
||||
end
|
||||
|
||||
defp error_message(:forbidden), do: "Moderator access is required."
|
||||
defp error_message(:proposal_closed), do: "This proposal has already been reviewed."
|
||||
defp error_message(:cannot_restrict_self), do: "You cannot restrict your own moderator account."
|
||||
defp error_message(:last_admin), do: "The last administrator cannot demote themselves."
|
||||
defp error_message(%Ecto.Changeset{}), do: "Please check the submitted fields."
|
||||
defp error_message(reason), do: "Could not complete moderation: #{inspect(reason)}"
|
||||
defp error_message(:forbidden), do: gettext("Moderator access is required.")
|
||||
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.")
|
||||
|
||||
defp error_message(:cannot_restrict_self),
|
||||
do: gettext("You cannot restrict your own moderator account.")
|
||||
|
||||
defp error_message(:last_admin),
|
||||
do: gettext("The last administrator cannot demote themselves.")
|
||||
|
||||
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
|
||||
|
||||
defp error_message(reason),
|
||||
do: gettext("Could not complete moderation: %{reason}", reason: inspect(reason))
|
||||
|
||||
defp scoped_form(data, as, scope) do
|
||||
to_form(data, as: as, id: "#{as}-#{scope}")
|
||||
end
|
||||
|
||||
defp reason_label(:dangerous_request), do: gettext("Dangerous request")
|
||||
defp reason_label(:harassment), do: gettext("Harassment")
|
||||
defp reason_label(:fraud), do: gettext("Fraud")
|
||||
defp reason_label(:prohibited_item), do: gettext("Prohibited item")
|
||||
defp reason_label(:spam), do: gettext("Spam")
|
||||
defp reason_label(:impersonation), do: gettext("Impersonation")
|
||||
defp reason_label(:other), do: gettext("Other")
|
||||
defp reason_label(value), do: to_string(value)
|
||||
|
||||
defp status_label(:open), do: gettext("Open")
|
||||
defp status_label(:reviewing), do: gettext("Reviewing")
|
||||
defp status_label(:resolved), do: gettext("Resolved")
|
||||
defp status_label(:dismissed), do: gettext("Dismissed")
|
||||
defp status_label(:reviewed), do: gettext("Reviewed")
|
||||
defp status_label(:approved), do: gettext("Approved")
|
||||
defp status_label(:rejected), do: gettext("Rejected")
|
||||
defp status_label(:merged), do: gettext("Merged")
|
||||
defp status_label(:active), do: gettext("Active")
|
||||
defp status_label(:restricted), do: gettext("Restricted")
|
||||
defp status_label(:suspended), do: gettext("Suspended")
|
||||
defp status_label(value), do: to_string(value)
|
||||
|
||||
defp role_label(:user), do: gettext("User")
|
||||
defp role_label(:moderator), do: gettext("Moderator")
|
||||
defp role_label(:admin), do: gettext("Administrator")
|
||||
defp role_label(value), do: to_string(value)
|
||||
|
||||
defp signal_label(:velocity), do: gettext("Unusual action rate")
|
||||
defp signal_label(:repeated_pair), do: gettext("Repeated participant pair")
|
||||
|
||||
defp signal_label(:handover_without_location_evidence),
|
||||
do: gettext("Handover without location evidence")
|
||||
|
||||
defp signal_label(:location_without_movement), do: gettext("Location without movement")
|
||||
defp signal_label(:reciprocal_ring), do: gettext("Reciprocal rating ring")
|
||||
defp signal_label(:manual), do: gettext("Manual review")
|
||||
defp signal_label(value), do: to_string(value)
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="text-sm font-semibold text-warning">RESTRICTED WORKSPACE</div>
|
||||
<h1 class="mt-1 text-4xl font-black">Moderation</h1>
|
||||
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div>
|
||||
<h1 class="mt-1 text-4xl font-black">{gettext("Moderation")}</h1>
|
||||
<p class="mt-2 text-base-content/60">
|
||||
Decisions and access to reported chat evidence are written to the audit log.
|
||||
{gettext("Decisions and access to reported chat evidence are written to the audit log.")}
|
||||
</p>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-2xl font-bold">Reports</h2>
|
||||
<h2 class="text-2xl font-bold">{gettext("Reports")}</h2>
|
||||
<div class="mt-4 space-y-4">
|
||||
<article :for={report <- @reports} class="rounded-2xl border border-base-300 p-5">
|
||||
<% hide_form = scoped_form(%{"note" => ""}, :moderation, "report-hide-#{report.id}") %>
|
||||
|
|
@ -182,13 +241,17 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
"report-review-#{report.id}"
|
||||
) %>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-error">{report.reason}</span>
|
||||
<span class="badge">{report.status}</span>
|
||||
<span :if={report.activity_id} class="badge badge-info badge-outline">activity</span>
|
||||
<span :if={report.activity_message_id} class="badge badge-info badge-outline">
|
||||
activity message
|
||||
<span class="badge badge-error">{reason_label(report.reason)}</span>
|
||||
<span class="badge">{status_label(report.status)}</span>
|
||||
<span :if={report.activity_id} class="badge badge-info badge-outline">
|
||||
{gettext("activity")}
|
||||
</span>
|
||||
<span :if={report.activity_message_id} class="badge badge-info badge-outline">
|
||||
{gettext("activity message")}
|
||||
</span>
|
||||
<span class="text-xs text-base-content/50">
|
||||
{gettext("by %{name}", name: report.reporter.display_name)}
|
||||
</span>
|
||||
<span class="text-xs text-base-content/50">by {report.reporter.display_name}</span>
|
||||
</div>
|
||||
<p class="mt-3 whitespace-pre-wrap text-sm">{report.details}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
|
|
@ -197,7 +260,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
phx-value-id={report.id}
|
||||
class="btn btn-sm btn-outline"
|
||||
>
|
||||
View scoped evidence
|
||||
{gettext("View scoped evidence")}
|
||||
</button>
|
||||
<.form
|
||||
:if={report.request_id}
|
||||
|
|
@ -206,8 +269,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
phx-value-id={report.request_id}
|
||||
class="flex gap-2"
|
||||
>
|
||||
<.input field={hide_form[:note]} placeholder="Hide reason" />
|
||||
<.button class="btn btn-sm btn-error self-end">Hide request</.button>
|
||||
<.input field={hide_form[:note]} placeholder={gettext("Hide reason")} />
|
||||
<.button class="btn btn-sm btn-error self-end">
|
||||
{gettext("Hide request")}
|
||||
</.button>
|
||||
</.form>
|
||||
<.form
|
||||
:if={report.activity_id}
|
||||
|
|
@ -216,8 +281,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
phx-value-id={report.activity_id}
|
||||
class="flex gap-2"
|
||||
>
|
||||
<.input field={hide_form[:note]} placeholder="Hide reason" />
|
||||
<.button class="btn btn-sm btn-error self-end">Hide activity</.button>
|
||||
<.input field={hide_form[:note]} placeholder={gettext("Hide reason")} />
|
||||
<.button class="btn btn-sm btn-error self-end">
|
||||
{gettext("Hide activity")}
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
<.form
|
||||
|
|
@ -229,23 +296,30 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
<.input
|
||||
field={report_form[:status]}
|
||||
type="select"
|
||||
options={["reviewing", "resolved", "dismissed"]}
|
||||
options={[
|
||||
{gettext("Reviewing"), "reviewing"},
|
||||
{gettext("Resolved"), "resolved"},
|
||||
{gettext("Dismissed"), "dismissed"}
|
||||
]}
|
||||
/>
|
||||
<.input field={report_form[:resolution_note]} placeholder="Resolution note" />
|
||||
<.button class="btn btn-primary self-end">Save</.button>
|
||||
<.input
|
||||
field={report_form[:resolution_note]}
|
||||
placeholder={gettext("Resolution note")}
|
||||
/>
|
||||
<.button class="btn btn-primary self-end">{gettext("Save")}</.button>
|
||||
</.form>
|
||||
</article>
|
||||
<p :if={@reports == []} class="text-base-content/50">No reports.</p>
|
||||
<p :if={@reports == []} class="text-base-content/50">{gettext("No reports.")}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :if={@evidence} class="mt-8 rounded-3xl bg-neutral p-6 text-neutral-content">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold">Reported evidence</h2>
|
||||
<button phx-click="close-evidence" class="btn btn-sm">Close</button>
|
||||
<h2 class="text-xl font-bold">{gettext("Reported evidence")}</h2>
|
||||
<button phx-click="close-evidence" class="btn btn-sm">{gettext("Close")}</button>
|
||||
</div>
|
||||
<p class="mt-3 text-sm opacity-70">
|
||||
Only the conversation linked to this report is displayed.
|
||||
{gettext("Only the conversation linked to this report is displayed.")}
|
||||
</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div :for={message <- @evidence.messages} class="rounded-xl bg-white/10 p-3 text-sm">
|
||||
|
|
@ -261,13 +335,13 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
:if={@evidence.messages == [] and @evidence.activity_messages == []}
|
||||
class="text-sm opacity-60"
|
||||
>
|
||||
This report has no linked conversation.
|
||||
{gettext("This report has no linked conversation.")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-2xl font-bold">Open abuse signals</h2>
|
||||
<h2 class="text-2xl font-bold">{gettext("Open abuse signals")}</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<article :for={signal <- @signals} class="rounded-2xl bg-base-200 p-5">
|
||||
<% signal_form =
|
||||
|
|
@ -276,9 +350,13 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
:moderation,
|
||||
"signal-#{signal.id}"
|
||||
) %>
|
||||
<div class="font-semibold">{signal.kind} · {signal.subject.display_name}</div>
|
||||
<div class="font-semibold">
|
||||
{signal_label(signal.kind)} · {signal.subject.display_name}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/55">
|
||||
A signal requests human review; optional tracking absence is not proof of abuse.
|
||||
{gettext(
|
||||
"A signal requests human review; optional tracking absence is not proof of abuse."
|
||||
)}
|
||||
</p>
|
||||
<.form
|
||||
for={signal_form}
|
||||
|
|
@ -289,18 +367,23 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
<.input
|
||||
field={signal_form[:status]}
|
||||
type="select"
|
||||
options={["reviewed", "dismissed"]}
|
||||
options={[
|
||||
{gettext("Reviewed"), "reviewed"},
|
||||
{gettext("Dismissed"), "dismissed"}
|
||||
]}
|
||||
/>
|
||||
<.input field={signal_form[:review_note]} placeholder="Review note" />
|
||||
<.button class="btn btn-sm btn-primary self-end">Save</.button>
|
||||
<.input field={signal_form[:review_note]} placeholder={gettext("Review note")} />
|
||||
<.button class="btn btn-sm btn-primary self-end">{gettext("Save")}</.button>
|
||||
</.form>
|
||||
</article>
|
||||
<p :if={@signals == []} class="text-base-content/50">No open signals.</p>
|
||||
<p :if={@signals == []} class="text-base-content/50">
|
||||
{gettext("No open signals.")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-2xl font-bold">Category proposals</h2>
|
||||
<h2 class="text-2xl font-bold">{gettext("Category proposals")}</h2>
|
||||
<div class="mt-4 space-y-4">
|
||||
<article :for={proposal <- @proposals} class="rounded-2xl border border-base-300 p-5">
|
||||
<% category_form =
|
||||
|
|
@ -314,10 +397,13 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
<% merge_form =
|
||||
scoped_form(%{}, :moderation, "merge-#{proposal.id}") %>
|
||||
<h3 class="font-bold">
|
||||
{proposal.proposed_name} <span class="badge">{proposal.status}</span>
|
||||
{proposal.proposed_name}
|
||||
<span class="badge">{status_label(proposal.status)}</span>
|
||||
</h3>
|
||||
<p class="mt-1 text-sm">{proposal.reason}</p>
|
||||
<p class="mt-1 text-xs text-base-content/50">{length(proposal.votes)} community votes</p>
|
||||
<p class="mt-1 text-xs text-base-content/50">
|
||||
{gettext("%{count} community votes", count: length(proposal.votes))}
|
||||
</p>
|
||||
<div :if={proposal.status == :open} class="mt-4 grid gap-4 xl:grid-cols-2">
|
||||
<.form
|
||||
for={category_form}
|
||||
|
|
@ -325,21 +411,38 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
phx-value-id={proposal.id}
|
||||
class="space-y-2 rounded-xl bg-base-200 p-4"
|
||||
>
|
||||
<.input field={category_form[:slug]} label="Slug" />
|
||||
<.input field={category_form[:name_en]} label="English name" />
|
||||
<.input field={category_form[:slug]} label={gettext("Slug")} />
|
||||
<.input field={category_form[:name_en]} label={gettext("English name")} />
|
||||
<.input
|
||||
field={category_form[:name_uk]}
|
||||
label="Ukrainian name (optional)"
|
||||
label={gettext("Ukrainian name (optional)")}
|
||||
/>
|
||||
<.input
|
||||
field={category_form[:name_ru]}
|
||||
label={gettext("Russian name (optional)")}
|
||||
/>
|
||||
<.input
|
||||
field={category_form[:description_en]}
|
||||
label={gettext("English description")}
|
||||
/>
|
||||
<.input
|
||||
field={category_form[:description_uk]}
|
||||
label={gettext("Ukrainian description (optional)")}
|
||||
/>
|
||||
<.input
|
||||
field={category_form[:description_ru]}
|
||||
label={gettext("Russian description (optional)")}
|
||||
/>
|
||||
<.input field={category_form[:name_ru]} label="Russian name (optional)" />
|
||||
<.input field={category_form[:description]} label="Description" />
|
||||
<.input
|
||||
field={category_form[:structured_fields_json]}
|
||||
type="textarea"
|
||||
label="Structured fields JSON"
|
||||
label={gettext("Structured fields JSON")}
|
||||
/>
|
||||
<.input field={category_form[:moderation_note]} label="Decision note" />
|
||||
<.button class="btn btn-success w-full">Create category</.button>
|
||||
<.input
|
||||
field={category_form[:moderation_note]}
|
||||
label={gettext("Decision note")}
|
||||
/>
|
||||
<.button class="btn btn-success w-full">{gettext("Create category")}</.button>
|
||||
</.form>
|
||||
<div class="space-y-3">
|
||||
<.form
|
||||
|
|
@ -348,8 +451,8 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
phx-value-id={proposal.id}
|
||||
class="flex gap-2"
|
||||
>
|
||||
<.input field={reject_form[:note]} placeholder="Rejection note" />
|
||||
<.button class="btn btn-error self-end">Reject</.button>
|
||||
<.input field={reject_form[:note]} placeholder={gettext("Rejection note")} />
|
||||
<.button class="btn btn-error self-end">{gettext("Reject")}</.button>
|
||||
</.form>
|
||||
<.form
|
||||
for={merge_form}
|
||||
|
|
@ -360,13 +463,13 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
<.input
|
||||
field={merge_form[:category_id]}
|
||||
type="select"
|
||||
label="Merge into"
|
||||
label={gettext("Merge into")}
|
||||
options={
|
||||
Enum.map(@categories, &{WhoNeedHelp.Catalog.Category.name(&1, "en"), &1.id})
|
||||
}
|
||||
/>
|
||||
<.input field={merge_form[:note]} placeholder="Merge note" />
|
||||
<.button class="btn btn-outline w-full">Merge</.button>
|
||||
<.input field={merge_form[:note]} placeholder={gettext("Merge note")} />
|
||||
<.button class="btn btn-outline w-full">{gettext("Merge")}</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -375,12 +478,15 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-2xl font-bold">Accounts</h2>
|
||||
<h2 class="text-2xl font-bold">{gettext("Accounts")}</h2>
|
||||
<div class="mt-4 overflow-x-auto rounded-2xl border border-base-300">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th><th>Role</th><th>Status</th><th>Decision</th>
|
||||
<th>{gettext("User")}</th>
|
||||
<th>{gettext("Role")}</th>
|
||||
<th>{gettext("Status")}</th>
|
||||
<th>{gettext("Decision")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -404,8 +510,8 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
{user.display_name}
|
||||
<div class="text-xs opacity-50">{user.email}</div>
|
||||
</td>
|
||||
<td>{user.role}</td>
|
||||
<td>{user.moderation_status}</td>
|
||||
<td>{role_label(user.role)}</td>
|
||||
<td>{status_label(user.moderation_status)}</td>
|
||||
<td>
|
||||
<.form
|
||||
for={user_form}
|
||||
|
|
@ -416,13 +522,17 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
<.input
|
||||
field={user_form[:moderation_status]}
|
||||
type="select"
|
||||
options={["active", "restricted", "suspended"]}
|
||||
options={[
|
||||
{gettext("Active"), "active"},
|
||||
{gettext("Restricted"), "restricted"},
|
||||
{gettext("Suspended"), "suspended"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={user_form[:moderation_note]}
|
||||
placeholder="Internal note"
|
||||
placeholder={gettext("Internal note")}
|
||||
/>
|
||||
<.button class="btn btn-sm btn-primary self-end">Save</.button>
|
||||
<.button class="btn btn-sm btn-primary self-end">{gettext("Save")}</.button>
|
||||
</.form>
|
||||
<.form
|
||||
:if={@current_scope.user.role == :admin}
|
||||
|
|
@ -434,9 +544,15 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
<.input
|
||||
field={role_form[:role]}
|
||||
type="select"
|
||||
options={["user", "moderator", "admin"]}
|
||||
options={[
|
||||
{gettext("User"), "user"},
|
||||
{gettext("Moderator"), "moderator"},
|
||||
{gettext("Administrator"), "admin"}
|
||||
]}
|
||||
/>
|
||||
<.button class="btn btn-sm btn-warning self-end">Change role</.button>
|
||||
<.button class="btn btn-sm btn-warning self-end">
|
||||
{gettext("Change role")}
|
||||
</.button>
|
||||
</.form>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Profile")
|
||||
|> assign(:page_title, gettext("Profile"))
|
||||
|> assign(:reputation, Trust.reputation(user.id))
|
||||
|> assign(:reviews, Trust.visible_reviews(user.id))
|
||||
|> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope))
|
||||
|
|
@ -24,11 +24,13 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
def handle_event("save", %{"user" => params}, socket) do
|
||||
case Accounts.update_user_profile(socket.assigns.current_scope.user, params) do
|
||||
{:ok, user} ->
|
||||
Gettext.put_locale(WhoNeedHelpWeb.Gettext, user.locale)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_scope, WhoNeedHelp.Accounts.Scope.for_user(user))
|
||||
|> assign(:form, to_form(Accounts.change_user_profile(user)))
|
||||
|> put_flash(:info, "Profile updated.")}
|
||||
|> put_flash(:info, gettext("Profile updated."))}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
|
|
@ -43,7 +45,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign_social_identities(user)
|
||||
|> put_flash(:info, "Social link added as unverified.")}
|
||||
|> put_flash(:info, gettext("Social link added as unverified."))}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :social_form, to_form(changeset, as: :social_identity))}
|
||||
|
|
@ -58,7 +60,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
{:noreply, assign_social_identities(socket, user)}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:noreply, put_flash(socket, :error, "Social link was not found.")}
|
||||
{:noreply, put_flash(socket, :error, gettext("Social link was not found."))}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -68,10 +70,15 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope))
|
||||
|> put_flash(:info, "User unblocked.")}
|
||||
|> put_flash(:info, gettext("User unblocked."))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Could not unblock: #{inspect(reason)}")}
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Could not unblock: %{reason}", reason: inspect(reason))
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -92,72 +99,78 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<h1 class="text-4xl font-black">Your profile</h1>
|
||||
<h1 class="text-4xl font-black">{gettext("Your profile")}</h1>
|
||||
<div class="mt-6 grid gap-6 md:grid-cols-[1fr_.75fr]">
|
||||
<.form
|
||||
for={@form}
|
||||
phx-submit="save"
|
||||
class="space-y-4 rounded-3xl border border-base-300 p-6"
|
||||
>
|
||||
<.input field={@form[:display_name]} label="Display name" />
|
||||
<.input field={@form[:bio]} type="textarea" label="Short bio" />
|
||||
<.input field={@form[:display_name]} label={gettext("Display name")} />
|
||||
<.input field={@form[:bio]} type="textarea" label={gettext("Short bio")} />
|
||||
<.input
|
||||
field={@form[:locale]}
|
||||
type="select"
|
||||
label="Interface language"
|
||||
options={[{"English", "en"}, {"Українська", "uk"}, {"Русский", "ru"}]}
|
||||
label={gettext("Interface language")}
|
||||
options={[
|
||||
{gettext("English"), "en"},
|
||||
{"Українська", "uk"},
|
||||
{"Русский", "ru"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:location_visibility]}
|
||||
type="select"
|
||||
label="Default location visibility"
|
||||
label={gettext("Default location visibility")}
|
||||
options={[
|
||||
{"Hidden", "hidden"},
|
||||
{"Approximate publicly", "approximate_public"},
|
||||
{"Exact for active match", "exact_for_active_match"},
|
||||
{"Exact publicly", "exact_public"}
|
||||
{gettext("Hidden"), "hidden"},
|
||||
{gettext("Approximate publicly"), "approximate_public"},
|
||||
{gettext("Exact for active match"), "exact_for_active_match"},
|
||||
{gettext("Exact publicly"), "exact_public"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:tip_url]}
|
||||
label="Optional external thank-you link"
|
||||
label={gettext("Optional external thank-you link")}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
<p class="text-xs text-base-content/55">
|
||||
The platform never handles, requires, splits, or guarantees a payment.
|
||||
{gettext("The platform never handles, requires, splits, or guarantees a payment.")}
|
||||
</p>
|
||||
<.button class="btn btn-primary w-full">Save profile</.button>
|
||||
<.button class="btn btn-primary w-full">{gettext("Save profile")}</.button>
|
||||
</.form>
|
||||
|
||||
<aside class="space-y-5">
|
||||
<div class="rounded-3xl bg-success p-6 text-success-content">
|
||||
<h2 class="font-bold">Public reputation</h2>
|
||||
<h2 class="font-bold">{gettext("Public reputation")}</h2>
|
||||
<div class="mt-5 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-3xl font-black">{@reputation.completed}</div><div class="text-xs">
|
||||
completed
|
||||
{gettext("completed")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-black">{@reputation.unique_people}</div><div class="text-xs">
|
||||
unique people
|
||||
{gettext("unique people")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-black">{@reputation.verified_handovers}</div><div class="text-xs">
|
||||
verified handovers
|
||||
{gettext("verified handovers")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-black">{@reputation.rating || "—"}</div><div class="text-xs">
|
||||
rating
|
||||
{gettext("rating")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl bg-base-200 p-5">
|
||||
<h3 class="font-bold">Visible reviews</h3>
|
||||
<p :if={@reviews == []} class="mt-3 text-sm text-base-content/55">None revealed yet.</p>
|
||||
<h3 class="font-bold">{gettext("Visible reviews")}</h3>
|
||||
<p :if={@reviews == []} class="mt-3 text-sm text-base-content/55">
|
||||
{gettext("None revealed yet.")}
|
||||
</p>
|
||||
<div :for={review <- @reviews} class="mt-3 border-t border-base-300 pt-3 text-sm">
|
||||
<div class="font-semibold">{"★" |> String.duplicate(review.rating)}</div>
|
||||
<div class="mt-1">{review.comment}</div>
|
||||
|
|
@ -169,10 +182,11 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
<section class="mt-6 rounded-3xl border border-base-300 p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">Social links</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Social links")}</h2>
|
||||
<p class="mt-1 text-sm text-base-content/55">
|
||||
Manually added links are public and marked unverified. A verified badge is only
|
||||
granted after completing a provider authorization flow.
|
||||
{gettext(
|
||||
"Manually added links are public and marked unverified. A verified badge is only granted after completing a provider authorization flow."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<.link
|
||||
|
|
@ -180,7 +194,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
href={~p"/auth/social/github"}
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Verify GitHub
|
||||
{gettext("Verify GitHub")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
|
|
@ -188,14 +202,15 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
:if={!@github_oauth_enabled}
|
||||
class="mt-4 rounded-2xl bg-base-200 p-4 text-sm text-base-content/60"
|
||||
>
|
||||
GitHub verification is unavailable until the operator configures its OAuth
|
||||
credentials. No manually entered link is treated as verified.
|
||||
{gettext(
|
||||
"GitHub verification is unavailable until the operator configures its OAuth credentials. No manually entered link is treated as verified."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-[1fr_.9fr]">
|
||||
<div class="space-y-3">
|
||||
<p :if={@social_identities == []} class="text-sm text-base-content/55">
|
||||
No social links added.
|
||||
{gettext("No social links added.")}
|
||||
</p>
|
||||
<div
|
||||
:for={identity <- @social_identities}
|
||||
|
|
@ -209,7 +224,9 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
identity.verified_at && "badge-success",
|
||||
is_nil(identity.verified_at) && "badge-warning"
|
||||
]}>
|
||||
{if identity.verified_at, do: "verified", else: "unverified"}
|
||||
{if identity.verified_at,
|
||||
do: gettext("verified"),
|
||||
else: gettext("unverified")}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
|
|
@ -227,7 +244,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
phx-value-id={identity.id}
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
>
|
||||
Remove
|
||||
{gettext("Remove")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -236,32 +253,36 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
<.input
|
||||
field={@social_form[:provider]}
|
||||
type="select"
|
||||
label="Network"
|
||||
label={gettext("Network")}
|
||||
options={[
|
||||
{"GitHub", "github"},
|
||||
{"Instagram", "instagram"},
|
||||
{"Facebook", "facebook"},
|
||||
{"Telegram", "telegram"},
|
||||
{"Google", "google"},
|
||||
{"Other", "other"}
|
||||
{gettext("Other"), "other"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@social_form[:profile_url]}
|
||||
type="url"
|
||||
label="Public profile URL"
|
||||
label={gettext("Public profile URL")}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
<.input field={@social_form[:handle]} label="Handle (optional)" placeholder="@name" />
|
||||
<.button class="btn btn-outline w-full">Add unverified link</.button>
|
||||
<.input
|
||||
field={@social_form[:handle]}
|
||||
label={gettext("Handle (optional)")}
|
||||
placeholder="@name"
|
||||
/>
|
||||
<.button class="btn btn-outline w-full">{gettext("Add unverified link")}</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 rounded-3xl border border-base-300 p-6">
|
||||
<h2 class="text-xl font-bold">Blocked users</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Blocked users")}</h2>
|
||||
<p :if={@blocks == []} class="mt-3 text-sm text-base-content/55">
|
||||
You have not blocked anyone.
|
||||
{gettext("You have not blocked anyone.")}
|
||||
</p>
|
||||
<div
|
||||
:for={block <- @blocks}
|
||||
|
|
@ -273,7 +294,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
phx-value-id={block.blocked_id}
|
||||
class="btn btn-sm btn-outline"
|
||||
>
|
||||
Unblock
|
||||
{gettext("Unblock")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
user = socket.assigns.current_scope.user
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Nearby help")
|
||||
|> assign(:page_title, gettext("Nearby help"))
|
||||
|> assign(:requests, requests)
|
||||
|> assign(:my_requests, Help.list_my_requests(socket.assigns.current_scope))
|
||||
|> assign(:reputation, Trust.reputation(user.id))
|
||||
|
|
@ -51,24 +51,52 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
defp urgency_label(:now), do: gettext("Now")
|
||||
defp urgency_label("now"), do: gettext("Now")
|
||||
defp urgency_label(:today), do: gettext("Today")
|
||||
defp urgency_label("today"), do: gettext("Today")
|
||||
defp urgency_label(:scheduled), do: gettext("Scheduled")
|
||||
defp urgency_label("scheduled"), do: gettext("Scheduled")
|
||||
defp urgency_label(value), do: value
|
||||
|
||||
defp status_label(:open), do: gettext("Open")
|
||||
defp status_label("open"), do: gettext("Open")
|
||||
defp status_label(:matched), do: gettext("Matched")
|
||||
defp status_label("matched"), do: gettext("Matched")
|
||||
defp status_label(:in_progress), do: gettext("In progress")
|
||||
defp status_label("in_progress"), do: gettext("In progress")
|
||||
defp status_label(:completed), do: gettext("Completed")
|
||||
defp status_label("completed"), do: gettext("Completed")
|
||||
defp status_label(:cancelled), do: gettext("Cancelled")
|
||||
defp status_label("cancelled"), do: gettext("Cancelled")
|
||||
defp status_label(:expired), do: gettext("Expired")
|
||||
defp status_label("expired"), do: gettext("Expired")
|
||||
defp status_label(value), do: value
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-success">NEARBY MUTUAL AID</div>
|
||||
<h1 class="mt-1 text-4xl font-black tracking-tight">Who needs help?</h1>
|
||||
<div class="text-sm font-semibold text-success">{gettext("NEARBY MUTUAL AID")}</div>
|
||||
<h1 class="mt-1 text-4xl font-black tracking-tight">{gettext("Who needs help?")}</h1>
|
||||
<p class="mt-2 text-base-content/65">
|
||||
Only approximate areas are shown before a safe match.
|
||||
{gettext("Only approximate areas are shown before a safe match.")}
|
||||
</p>
|
||||
</div>
|
||||
<.link navigate={~p"/requests/new"} class="btn btn-primary">Create urgent request</.link>
|
||||
<.link navigate={~p"/requests/new"} class="btn btn-primary">
|
||||
{gettext("Create urgent request")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mt-5 text-sm">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<span>This is not an emergency service. Contact local emergency services for immediate danger.</span>
|
||||
<span>
|
||||
{gettext(
|
||||
"This is not an emergency service. Contact local emergency services for immediate danger."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
|
|
@ -79,24 +107,27 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
<.input
|
||||
field={@filter_form[:category_id]}
|
||||
type="select"
|
||||
label="Category"
|
||||
label={gettext("Category")}
|
||||
options={[
|
||||
{"All categories", ""}
|
||||
{gettext("All categories"), ""}
|
||||
| Enum.map(
|
||||
@categories,
|
||||
&{WhoNeedHelp.Catalog.category_path(&1, @current_scope.user.locale), &1.id}
|
||||
&{WhoNeedHelp.Catalog.category_path(
|
||||
&1,
|
||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||
), &1.id}
|
||||
)
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@filter_form[:urgency]}
|
||||
type="select"
|
||||
label="Urgency"
|
||||
label={gettext("Urgency")}
|
||||
options={[
|
||||
{"Any urgency", ""},
|
||||
{"Now", "now"},
|
||||
{"Today", "today"},
|
||||
{"Scheduled", "scheduled"}
|
||||
{gettext("Any urgency"), ""},
|
||||
{gettext("Now"), "now"},
|
||||
{gettext("Today"), "today"},
|
||||
{gettext("Scheduled"), "scheduled"}
|
||||
]}
|
||||
/>
|
||||
</.form>
|
||||
|
|
@ -105,9 +136,9 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
<div class="space-y-4">
|
||||
<%= if @requests == [] do %>
|
||||
<div class="rounded-3xl border border-dashed border-base-300 p-12 text-center">
|
||||
<h2 class="text-xl font-bold">No open requests yet</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("No open requests yet")}</h2>
|
||||
<p class="mt-2 text-base-content/60">
|
||||
You can be the first person to ask the community.
|
||||
{gettext("You can be the first person to ask the community.")}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
@ -121,9 +152,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
<div>
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<span class="badge badge-success badge-outline">
|
||||
{WhoNeedHelp.Catalog.Category.name(request.category, @current_scope.user.locale)}
|
||||
{WhoNeedHelp.Catalog.Category.name(
|
||||
request.category,
|
||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||
)}
|
||||
</span>
|
||||
<span class="badge badge-error badge-outline">{request.urgency}</span>
|
||||
<span class="badge badge-error badge-outline">{urgency_label(request.urgency)}</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold">{request.title}</h2>
|
||||
<p class="mt-2 line-clamp-2 text-sm text-base-content/65">{request.description}</p>
|
||||
|
|
@ -132,8 +166,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
</div>
|
||||
<div class="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/55">
|
||||
<span>📍 {request.location_label}</span>
|
||||
<span>by {request.requester.display_name || "Community member"}</span>
|
||||
<span>expires {Calendar.strftime(request.expires_at, "%d %b, %H:%M UTC")}</span>
|
||||
<span>
|
||||
{gettext("by %{name}",
|
||||
name: request.requester.display_name || gettext("Community member")
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{gettext("expires %{time}",
|
||||
time: Calendar.strftime(request.expires_at, "%d.%m.%Y, %H:%M UTC")
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</.link>
|
||||
</div>
|
||||
|
|
@ -147,21 +189,21 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
class="aid-map"
|
||||
/>
|
||||
<div class="rounded-3xl bg-base-200 p-5">
|
||||
<h3 class="font-bold">Your trust summary</h3>
|
||||
<h3 class="font-bold">{gettext("Your trust summary")}</h3>
|
||||
<div class="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-black">{@reputation.completed}</div><div class="text-[.65rem]">
|
||||
completed
|
||||
{gettext("completed")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-black">{@reputation.unique_people}</div><div class="text-[.65rem]">
|
||||
people
|
||||
{gettext("people")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-black">{@reputation.verified_handovers}</div><div class="text-[.65rem]">
|
||||
verified
|
||||
{gettext("verified")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -170,14 +212,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
</div>
|
||||
|
||||
<section :if={@my_requests != []} class="mt-12">
|
||||
<h2 class="text-2xl font-bold">Your requests</h2>
|
||||
<h2 class="text-2xl font-bold">{gettext("Your requests")}</h2>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<.link
|
||||
:for={request <- @my_requests}
|
||||
navigate={~p"/requests/#{request.id}"}
|
||||
class="rounded-2xl bg-base-200 p-4"
|
||||
>
|
||||
<span class="badge badge-sm">{request.status}</span>
|
||||
<span class="badge badge-sm">{status_label(request.status)}</span>
|
||||
<div class="mt-2 font-semibold">{request.title}</div>
|
||||
</.link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Ask for help")
|
||||
|> assign(:page_title, gettext("Ask for help"))
|
||||
|> assign(:categories, Catalog.list_categories())
|
||||
|> assign(:selected_category, nil)
|
||||
|> assign(:structured_fields, [])
|
||||
|
|
@ -44,7 +44,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
{:ok, request} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Your request is now visible to nearby helpers.")
|
||||
|> put_flash(:info, gettext("Your request is now visible to nearby helpers."))
|
||||
|> push_navigate(to: ~p"/requests/#{request.id}")}
|
||||
|
||||
{:error, changeset} ->
|
||||
|
|
@ -101,54 +101,67 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
form.params |> Map.get("structured_data", %{}) |> Map.get(key)
|
||||
end
|
||||
|
||||
defp category_description(nil), do: nil
|
||||
defp category_description(category), do: category.description
|
||||
defp category_description(nil, _locale), do: nil
|
||||
|
||||
defp category_description(category, locale),
|
||||
do: WhoNeedHelp.Catalog.Category.description(category, locale)
|
||||
|
||||
defp category_title_placeholder(%{slug: "medicine-pickup"}),
|
||||
do: "Medicine is ready at the pharmacy"
|
||||
do: gettext("Medicine is ready at the pharmacy")
|
||||
|
||||
defp category_title_placeholder(%{parent: %{slug: "roadside-help"}}),
|
||||
do: "Need roadside help in a safe location"
|
||||
do: gettext("Need roadside help in a safe location")
|
||||
|
||||
defp category_title_placeholder(_category), do: "Briefly describe the help you need"
|
||||
defp category_title_placeholder(_category), do: gettext("Briefly describe the help you need")
|
||||
|
||||
defp safety_confirmation(%{slug: "medicine-pickup"}) do
|
||||
"I confirm this is not an emergency, medical advice request, controlled substance, " <>
|
||||
"or request to buy medicine on my behalf."
|
||||
gettext(
|
||||
"I confirm this is not an emergency, medical advice request, controlled substance, or request to buy medicine on my behalf."
|
||||
)
|
||||
end
|
||||
|
||||
defp safety_confirmation(%{parent: %{slug: "roadside-help"}}) do
|
||||
"I confirm there is no immediate danger, people and vehicles are away from active traffic " <>
|
||||
"or emergency services have already been contacted, and the requested task is lawful."
|
||||
gettext(
|
||||
"I confirm there is no immediate danger, people and vehicles are away from active traffic or emergency services have already been contacted, and the requested task is lawful."
|
||||
)
|
||||
end
|
||||
|
||||
defp safety_confirmation(_category) do
|
||||
gettext(
|
||||
"I confirm this is not an emergency, there is no immediate danger, and the requested task is lawful."
|
||||
)
|
||||
end
|
||||
|
||||
defp error_message(:account_not_eligible),
|
||||
do: "Confirm your email and ensure your account is active before publishing."
|
||||
do: gettext("Confirm your email and ensure your account is active before publishing.")
|
||||
|
||||
defp error_message(:rate_limited), do: "Too many requests in the configured time window."
|
||||
defp error_message(reason), do: "Could not publish the request: #{inspect(reason)}"
|
||||
defp error_message(:rate_limited),
|
||||
do: gettext("Too many requests in the configured time window.")
|
||||
|
||||
defp error_message(reason),
|
||||
do: gettext("Could not publish the request: %{reason}", reason: inspect(reason))
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm mb-4">← Back to requests</.link>
|
||||
<h1 class="text-4xl font-black">Ask for urgent help</h1>
|
||||
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm mb-4">
|
||||
{gettext("← Back to requests")}
|
||||
</.link>
|
||||
<h1 class="text-4xl font-black">{gettext("Ask for urgent help")}</h1>
|
||||
<p class="mt-2 text-base-content/65">
|
||||
Medicine pickup remains the first priority. Roadside categories use the same moderated,
|
||||
extensible request flow with details specific to each type of help.
|
||||
{gettext(
|
||||
"Medicine pickup remains the first priority. Roadside categories use the same moderated, extensible request flow with details specific to each type of help."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div class="alert alert-warning mt-6 text-sm">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<span>
|
||||
This is not an emergency service. Contact local emergency services for immediate danger.
|
||||
Never include prescription details, payment card data, access codes, or other sensitive information.
|
||||
{gettext(
|
||||
"This is not an emergency service. Contact local emergency services for immediate danger. Never include prescription details, payment card data, access codes, or other sensitive information."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -162,37 +175,50 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
<.input
|
||||
field={@form[:category_id]}
|
||||
type="select"
|
||||
label="Category"
|
||||
prompt="Choose category"
|
||||
label={gettext("Category")}
|
||||
prompt={gettext("Choose category")}
|
||||
options={
|
||||
Enum.map(
|
||||
@categories,
|
||||
&{WhoNeedHelp.Catalog.category_path(&1, @current_scope.user.locale), &1.id}
|
||||
&{WhoNeedHelp.Catalog.category_path(
|
||||
&1,
|
||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||
), &1.id}
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p
|
||||
:if={category_description(@selected_category)}
|
||||
:if={
|
||||
category_description(
|
||||
@selected_category,
|
||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||
)
|
||||
}
|
||||
id="selected-category-description"
|
||||
class="-mt-3 text-sm text-base-content/60"
|
||||
>
|
||||
{category_description(@selected_category)}
|
||||
{category_description(
|
||||
@selected_category,
|
||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
:if={@structured_fields != []}
|
||||
class="space-y-4 rounded-2xl border border-success/30 bg-success/5 p-5"
|
||||
>
|
||||
<h2 class="font-bold">Category details</h2>
|
||||
<h2 class="font-bold">{gettext("Category details")}</h2>
|
||||
<div :for={field <- @structured_fields}>
|
||||
<label class="fieldset">
|
||||
<span class="label">{field_label(field, @current_scope.user.locale)}</span>
|
||||
<span class="label">
|
||||
{field_label(field, Gettext.get_locale(WhoNeedHelpWeb.Gettext))}
|
||||
</span>
|
||||
<select
|
||||
:if={field["type"] == "select"}
|
||||
name={"help_request[structured_data][#{field["key"]}]"}
|
||||
required={field["required"] == true}
|
||||
class="select w-full"
|
||||
>
|
||||
<option value="">Choose…</option>
|
||||
<option value="">{gettext("Choose…")}</option>
|
||||
<option
|
||||
:for={option <- field["options"] || []}
|
||||
value={option_value(option)}
|
||||
|
|
@ -201,7 +227,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
to_string(option_value(option))
|
||||
}
|
||||
>
|
||||
{option_label(option, @current_scope.user.locale)}
|
||||
{option_label(option, Gettext.get_locale(WhoNeedHelpWeb.Gettext))}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
|
|
@ -219,12 +245,12 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
required={field["required"] == true}
|
||||
class="select w-full"
|
||||
>
|
||||
<option value="">Choose…</option>
|
||||
<option value="">{gettext("Choose…")}</option>
|
||||
<option value="true" selected={structured_value(@form, field["key"]) == "true"}>
|
||||
Yes
|
||||
{gettext("Yes")}
|
||||
</option>
|
||||
<option value="false" selected={structured_value(@form, field["key"]) == "false"}>
|
||||
No
|
||||
{gettext("No")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
|
@ -235,44 +261,58 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
</div>
|
||||
<.input
|
||||
field={@form[:title]}
|
||||
label="Short title"
|
||||
label={gettext("Short title")}
|
||||
placeholder={category_title_placeholder(@selected_category)}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:description]}
|
||||
type="textarea"
|
||||
label="What help do you need?"
|
||||
placeholder="Explain what is already arranged and what the volunteer needs to do."
|
||||
label={gettext("What help do you need?")}
|
||||
placeholder={
|
||||
gettext("Explain what is already arranged and what the volunteer needs to do.")
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:pickup_instructions]}
|
||||
type="textarea"
|
||||
label="Pickup instructions (optional)"
|
||||
placeholder="Share only non-sensitive instructions. Exact details can wait for the matched chat."
|
||||
label={gettext("Pickup instructions (optional)")}
|
||||
placeholder={
|
||||
gettext(
|
||||
"Share only non-sensitive instructions. Exact details can wait for the matched chat."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<.input
|
||||
field={@form[:urgency]}
|
||||
type="select"
|
||||
label="Urgency"
|
||||
options={[{"Now", "now"}, {"Today", "today"}, {"Scheduled", "scheduled"}]}
|
||||
label={gettext("Urgency")}
|
||||
options={[
|
||||
{gettext("Now"), "now"},
|
||||
{gettext("Today"), "today"},
|
||||
{gettext("Scheduled"), "scheduled"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:expires_at]}
|
||||
type="datetime-local"
|
||||
label={gettext("Request expires (UTC)")}
|
||||
/>
|
||||
<.input field={@form[:expires_at]} type="datetime-local" label="Request expires (UTC)" />
|
||||
</div>
|
||||
<.input
|
||||
field={@form[:location_label]}
|
||||
label="Approximate area"
|
||||
placeholder="Podil, near the central pharmacy"
|
||||
label={gettext("Approximate area")}
|
||||
placeholder={gettext("Podil, near the central pharmacy")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:location_visibility]}
|
||||
type="select"
|
||||
label="Location visibility"
|
||||
label={gettext("Location visibility")}
|
||||
options={[
|
||||
{"Approximate publicly (recommended)", "approximate_public"},
|
||||
{"Hidden", "hidden"},
|
||||
{"Exact only for active match", "exact_for_active_match"},
|
||||
{"Exact publicly — explicit consent", "exact_public"}
|
||||
{gettext("Approximate publicly (recommended)"), "approximate_public"},
|
||||
{gettext("Hidden"), "hidden"},
|
||||
{gettext("Exact only for active match"), "exact_for_active_match"},
|
||||
{gettext("Exact publicly — explicit consent"), "exact_public"}
|
||||
]}
|
||||
/>
|
||||
<div class="rounded-2xl bg-base-200 p-5">
|
||||
|
|
@ -282,10 +322,10 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
id="location-picker"
|
||||
class="btn btn-outline btn-sm"
|
||||
>
|
||||
Use my current foreground location
|
||||
{gettext("Use my current foreground location")}
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-base-content/60">
|
||||
You can also enter coordinates manually for local testing.
|
||||
{gettext("You can also enter coordinates manually for local testing.")}
|
||||
</p>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<.input
|
||||
|
|
@ -293,14 +333,14 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
id="request-latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
label="Latitude"
|
||||
label={gettext("Latitude")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:longitude]}
|
||||
id="request-longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
label="Longitude"
|
||||
label={gettext("Longitude")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -308,7 +348,12 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
<input type="checkbox" required class="checkbox checkbox-success mt-0.5" />
|
||||
<span>{safety_confirmation(@selected_category)}</span>
|
||||
</label>
|
||||
<.button class="btn btn-primary btn-lg w-full" phx-disable-with="Publishing…">Publish request</.button>
|
||||
<.button
|
||||
class="btn btn-primary btn-lg w-full"
|
||||
phx-disable-with={gettext("Publishing…")}
|
||||
>
|
||||
{gettext("Publish request")}
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
{:error, :not_found} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "This request is unavailable.")
|
||||
|> put_flash(:error, gettext("This request is unavailable."))
|
||||
|> push_navigate(to: ~p"/requests")}
|
||||
end
|
||||
end
|
||||
|
|
@ -49,7 +49,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
{:error, :not_found} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "This request is no longer available.")
|
||||
|> put_flash(:error, gettext("This request is no longer available."))
|
||||
|> push_navigate(to: ~p"/requests")}
|
||||
end
|
||||
end
|
||||
|
|
@ -89,7 +89,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
def handle_event("accept", _, socket) do
|
||||
case Help.accept_request(socket.assigns.current_scope, socket.assigns.request.id) do
|
||||
{:ok, _} ->
|
||||
{:noreply, put_flash(socket, :info, "You are matched. Use chat to coordinate safely.")}
|
||||
{:noreply,
|
||||
put_flash(socket, :info, gettext("You are matched. Use chat to coordinate safely."))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, message(reason))}
|
||||
|
|
@ -99,7 +100,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
def handle_event("cancel-request", _, socket) do
|
||||
case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id) do
|
||||
{:ok, _request} ->
|
||||
{:noreply, put_flash(socket, :info, "Request cancelled.")}
|
||||
{:noreply, put_flash(socket, :info, gettext("Request cancelled."))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, message(reason))}
|
||||
|
|
@ -107,18 +108,19 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
end
|
||||
|
||||
def handle_event("start", _, socket),
|
||||
do: transition(socket, &Help.start_assignment/2, "Help is in progress.")
|
||||
do: transition(socket, &Help.start_assignment/2, gettext("Help is in progress."))
|
||||
|
||||
def handle_event("confirm", _, socket),
|
||||
do: transition(socket, &Help.confirm_completion/2, "Your confirmation was saved.")
|
||||
do: transition(socket, &Help.confirm_completion/2, gettext("Your confirmation was saved."))
|
||||
|
||||
def handle_event("withdraw", _, socket),
|
||||
do: transition(socket, &Help.withdraw_assignment/2, "You withdrew from this request.")
|
||||
do:
|
||||
transition(socket, &Help.withdraw_assignment/2, gettext("You withdrew from this request."))
|
||||
|
||||
def handle_event("verify-code", %{"handover" => %{"code" => code}}, socket) do
|
||||
case Help.verify_handover(socket.assigns.current_scope, socket.assigns.assignment.id, code) do
|
||||
{:ok, _} ->
|
||||
{:noreply, put_flash(socket, :info, "Handover code verified.")}
|
||||
{:noreply, put_flash(socket, :info, gettext("Handover code verified."))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, message(reason))}
|
||||
|
|
@ -163,7 +165,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
end
|
||||
|
||||
def handle_event("location-error", _, socket) do
|
||||
{:noreply, put_flash(socket, :error, "The browser could not share your location.")}
|
||||
{:noreply, put_flash(socket, :error, gettext("The browser could not share your location."))}
|
||||
end
|
||||
|
||||
def handle_event("stop-tracking", _, socket) do
|
||||
|
|
@ -183,7 +185,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
case Trust.submit_review(socket.assigns.current_scope, socket.assigns.assignment, params) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :info, "Review saved. It appears after both participants review.")}
|
||||
put_flash(
|
||||
socket,
|
||||
:info,
|
||||
gettext("Review saved. It appears after both participants review.")
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, message(reason))}
|
||||
|
|
@ -210,7 +216,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
socket
|
||||
|> assign(:report_form, report_form())
|
||||
|> assign(:report_message_id, nil)
|
||||
|> put_flash(:info, "Report sent to moderators.")}
|
||||
|> put_flash(:info, gettext("Report sent to moderators."))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, message(reason))}
|
||||
|
|
@ -226,7 +232,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
if message do
|
||||
{:noreply, assign(socket, :report_message_id, message.id)}
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, "That message cannot be reported from this view.")}
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("That message cannot be reported from this view."))}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -246,7 +253,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
:markers,
|
||||
Jason.encode!(request_markers(socket.assigns.current_scope, socket.assigns.request))
|
||||
)
|
||||
|> put_flash(:info, "User blocked. Their requests and new messages are hidden.")}
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext("User blocked. Their requests and new messages are hidden.")
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, message(reason))}
|
||||
|
|
@ -259,7 +269,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocked_by_current, false)
|
||||
|> put_flash(:info, "User unblocked.")}
|
||||
|> put_flash(:info, gettext("User unblocked."))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, message(reason))}
|
||||
|
|
@ -335,7 +345,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
|> assign(:request, request)
|
||||
|> assign(
|
||||
:structured_details,
|
||||
structured_details(request, socket.assigns.current_scope.user.locale)
|
||||
structured_details(request, Gettext.get_locale(WhoNeedHelpWeb.Gettext))
|
||||
)
|
||||
|> assign(:requester_reputation, Trust.reputation(request.requester_id))
|
||||
|> assign(
|
||||
|
|
@ -418,8 +428,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
value ->
|
||||
label = localized(field["label"], locale) || field["key"]
|
||||
|
||||
display =
|
||||
if field["type"] == "select" do
|
||||
[{label, structured_value(field, value, locale)}]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp structured_value(%{"type" => "select"} = field, value, locale) do
|
||||
option =
|
||||
Enum.find(field["options"] || [], fn
|
||||
%{"value" => candidate} -> to_string(candidate) == to_string(value)
|
||||
|
|
@ -431,14 +445,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
nil -> to_string(value)
|
||||
candidate -> to_string(candidate)
|
||||
end
|
||||
else
|
||||
to_string(value)
|
||||
end
|
||||
|
||||
[{label, display}]
|
||||
end
|
||||
end)
|
||||
end
|
||||
defp structured_value(%{"type" => "boolean"}, value, _locale)
|
||||
when value in [true, "true", "1"],
|
||||
do: gettext("Yes")
|
||||
|
||||
defp structured_value(%{"type" => "boolean"}, _value, _locale), do: gettext("No")
|
||||
defp structured_value(_field, value, _locale), do: to_string(value)
|
||||
|
||||
defp localized(labels, locale) when is_map(labels),
|
||||
do: labels[to_string(locale)] || labels["en"]
|
||||
|
|
@ -466,32 +480,65 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
latitude: point.latitude,
|
||||
longitude: point.longitude,
|
||||
exact: true,
|
||||
title: "Shared live location",
|
||||
location: "Active match only"
|
||||
title: gettext("Shared live location"),
|
||||
location: gettext("Active match only")
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp message(:own_request), do: "You cannot accept your own request."
|
||||
defp message(:not_open), do: "Another helper already accepted this request."
|
||||
defp message(:invalid_code), do: "That handover code is not correct."
|
||||
defp message(:forbidden), do: "You are not allowed to do that."
|
||||
defp message(:invalid_transition), do: "That step is not available in the current state."
|
||||
defp message(:account_not_eligible), do: "Confirm your account and ensure it is active first."
|
||||
defp message(:blocked), do: "This interaction is blocked."
|
||||
defp message(:rate_limited), do: "Too many actions in the configured time window."
|
||||
defp message(%Ecto.Changeset{}), do: "Please check the entered data."
|
||||
defp message(reason), do: "Could not complete the action: #{inspect(reason)}"
|
||||
defp message(:own_request), do: gettext("You cannot accept your own request.")
|
||||
defp message(:not_open), do: gettext("Another helper already accepted this request.")
|
||||
defp message(:invalid_code), do: gettext("That handover code is not correct.")
|
||||
defp message(:forbidden), do: gettext("You are not allowed to do that.")
|
||||
|
||||
defp message(:invalid_transition),
|
||||
do: gettext("That step is not available in the current state.")
|
||||
|
||||
defp message(:account_not_eligible),
|
||||
do: gettext("Confirm your account and ensure it is active first.")
|
||||
|
||||
defp message(:blocked), do: gettext("This interaction is blocked.")
|
||||
defp message(:rate_limited), do: gettext("Too many actions in the configured time window.")
|
||||
defp message(%Ecto.Changeset{}), do: gettext("Please check the entered data.")
|
||||
|
||||
defp message(reason),
|
||||
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
|
||||
|
||||
defp urgency_label(:now), do: gettext("Now")
|
||||
defp urgency_label("now"), do: gettext("Now")
|
||||
defp urgency_label(:today), do: gettext("Today")
|
||||
defp urgency_label("today"), do: gettext("Today")
|
||||
defp urgency_label(:scheduled), do: gettext("Scheduled")
|
||||
defp urgency_label("scheduled"), do: gettext("Scheduled")
|
||||
defp urgency_label(value), do: value
|
||||
|
||||
defp status_label(:open), do: gettext("Open")
|
||||
defp status_label(:matched), do: gettext("Matched")
|
||||
defp status_label(:accepted), do: gettext("Accepted")
|
||||
defp status_label(:in_progress), do: gettext("In progress")
|
||||
defp status_label(:completed), do: gettext("Completed")
|
||||
defp status_label(:cancelled), do: gettext("Cancelled")
|
||||
defp status_label(value), do: value
|
||||
|
||||
defp visibility_label(:approximate_public), do: gettext("Approximate publicly")
|
||||
defp visibility_label(:hidden), do: gettext("Hidden")
|
||||
defp visibility_label(:exact_for_active_match), do: gettext("Exact only for active match")
|
||||
defp visibility_label(:exact_public), do: gettext("Exact publicly")
|
||||
defp visibility_label(value), do: value
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm">← All requests</.link>
|
||||
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm">
|
||||
{gettext("← All requests")}
|
||||
</.link>
|
||||
<div class="alert alert-warning mt-4 text-sm">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<span>
|
||||
This is not an emergency service. Contact local emergency services for immediate danger.
|
||||
{gettext(
|
||||
"This is not an emergency service. Contact local emergency services for immediate danger."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-7 lg:grid-cols-[minmax(0,1fr)_25rem]">
|
||||
|
|
@ -500,21 +547,23 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
<div class="flex flex-wrap gap-2">
|
||||
<span class="badge badge-success">{WhoNeedHelp.Catalog.Category.name(
|
||||
@request.category,
|
||||
@current_scope.user.locale
|
||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||
)}</span>
|
||||
<span class="badge badge-error badge-outline">{@request.urgency}</span>
|
||||
<span class="badge">{@request.status}</span>
|
||||
<span class="badge badge-error badge-outline">{urgency_label(@request.urgency)}</span>
|
||||
<span class="badge">{status_label(@request.status)}</span>
|
||||
</div>
|
||||
<h1 class="mt-5 text-4xl font-black tracking-tight">{@request.title}</h1>
|
||||
<p class="mt-5 whitespace-pre-wrap leading-7 text-base-content/75">
|
||||
{@request.description}
|
||||
</p>
|
||||
<div :if={@request.pickup_instructions} class="mt-5 rounded-2xl bg-base-200 p-5">
|
||||
<div class="text-xs font-bold uppercase tracking-wide">Pickup notes</div>
|
||||
<div class="text-xs font-bold uppercase tracking-wide">{gettext("Pickup notes")}</div>
|
||||
<p class="mt-2 whitespace-pre-wrap text-sm">{@request.pickup_instructions}</p>
|
||||
</div>
|
||||
<div :if={@structured_details != []} class="mt-5 rounded-2xl bg-success/10 p-5">
|
||||
<div class="text-xs font-bold uppercase tracking-wide">Category details</div>
|
||||
<div class="text-xs font-bold uppercase tracking-wide">
|
||||
{gettext("Category details")}
|
||||
</div>
|
||||
<dl class="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div :for={{label, value} <- @structured_details}>
|
||||
<dt class="text-base-content/50">{label}</dt>
|
||||
|
|
@ -523,25 +572,31 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
</dl>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-3 border-t border-base-300 pt-5 text-sm sm:grid-cols-2">
|
||||
<div><span class="text-base-content/50">Area:</span> {@request.location_label}</div>
|
||||
<div>
|
||||
<span class="text-base-content/50">Requester:</span> {@request.requester.display_name}
|
||||
<span class="text-base-content/50">{gettext("Area:")}</span>
|
||||
{@request.location_label}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/50">Expires:</span> {Calendar.strftime(
|
||||
<span class="text-base-content/50">{gettext("Requester:")}</span>
|
||||
{@request.requester.display_name}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/50">{gettext("Expires:")}</span>
|
||||
{Calendar.strftime(
|
||||
@request.expires_at,
|
||||
"%d %b, %H:%M UTC"
|
||||
"%d.%m.%Y, %H:%M UTC"
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/50">Location:</span> {@request.location_visibility}
|
||||
<span class="text-base-content/50">{gettext("Location:")}</span>
|
||||
{visibility_label(@request.location_visibility)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:if={@request.requester.social_identities != []}
|
||||
class="mt-4 flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span class="text-xs text-base-content/50">Requester links:</span>
|
||||
<span class="text-xs text-base-content/50">{gettext("Requester links:")}</span>
|
||||
<a
|
||||
:for={identity <- @request.requester.social_identities}
|
||||
href={identity.profile_url}
|
||||
|
|
@ -549,33 +604,37 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
rel="noopener noreferrer nofollow"
|
||||
class="badge badge-warning badge-outline"
|
||||
>
|
||||
{identity.provider} · {if identity.verified_at, do: "verified", else: "unverified"}
|
||||
{identity.provider} · {if identity.verified_at,
|
||||
do: gettext("verified"),
|
||||
else: gettext("unverified")}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
<span class="badge badge-outline">
|
||||
Requester: {@requester_reputation.completed} completed
|
||||
{gettext("Requester: %{count} completed",
|
||||
count: @requester_reputation.completed
|
||||
)}
|
||||
</span>
|
||||
<span class="badge badge-outline">
|
||||
{@requester_reputation.unique_people} unique people
|
||||
{gettext("%{count} unique people", count: @requester_reputation.unique_people)}
|
||||
</span>
|
||||
<span class="badge badge-outline">
|
||||
rating {@requester_reputation.rating || "—"}
|
||||
{gettext("rating %{rating}", rating: @requester_reputation.rating || "—")}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :if={@participant} class="rounded-3xl border border-base-300 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold">Private match chat</h2>
|
||||
<span class="badge badge-success badge-sm">participants only</span>
|
||||
<h2 class="text-xl font-bold">{gettext("Private match chat")}</h2>
|
||||
<span class="badge badge-success badge-sm">{gettext("participants only")}</span>
|
||||
</div>
|
||||
<div
|
||||
id="messages"
|
||||
class="mt-5 max-h-80 space-y-3 overflow-y-auto rounded-2xl bg-base-200 p-4"
|
||||
>
|
||||
<p :if={@messages == []} class="py-6 text-center text-sm text-base-content/50">
|
||||
No messages yet.
|
||||
{gettext("No messages yet.")}
|
||||
</p>
|
||||
<div
|
||||
:for={message <- @messages}
|
||||
|
|
@ -593,7 +652,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
phx-value-id={message.id}
|
||||
class="btn btn-ghost btn-xs mt-1 text-error"
|
||||
>
|
||||
Report message
|
||||
{gettext("Report message")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -605,10 +664,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
>
|
||||
<.input
|
||||
field={@message_form[:body]}
|
||||
placeholder="Write a safe coordination message…"
|
||||
placeholder={gettext("Write a safe coordination message…")}
|
||||
class="grow"
|
||||
/>
|
||||
<.button class="btn btn-primary self-end">Send</.button>
|
||||
<.button class="btn btn-primary self-end">{gettext("Send")}</.button>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
|
|
@ -616,25 +675,29 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
:if={@assignment && @assignment.status == :completed && @participant}
|
||||
class="rounded-3xl bg-base-200 p-6"
|
||||
>
|
||||
<h2 class="text-xl font-bold">Double-blind review</h2>
|
||||
<h2 class="text-xl font-bold">{gettext("Double-blind review")}</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
Your review is revealed only after both participants submit.
|
||||
{gettext("Your review is revealed only after both participants submit.")}
|
||||
</p>
|
||||
<.form for={@review_form} phx-submit="review" class="mt-4 space-y-3">
|
||||
<.input
|
||||
field={@review_form[:rating]}
|
||||
type="select"
|
||||
label="Rating"
|
||||
label={gettext("Rating")}
|
||||
options={[
|
||||
{"5 — excellent", 5},
|
||||
{"4 — good", 4},
|
||||
{"3 — okay", 3},
|
||||
{"2 — poor", 2},
|
||||
{"1 — unsafe", 1}
|
||||
{gettext("5 — excellent"), 5},
|
||||
{gettext("4 — good"), 4},
|
||||
{gettext("3 — okay"), 3},
|
||||
{gettext("2 — poor"), 2},
|
||||
{gettext("1 — unsafe"), 1}
|
||||
]}
|
||||
/>
|
||||
<.input field={@review_form[:comment]} type="textarea" label="Comment (optional)" />
|
||||
<.button class="btn btn-outline">Submit review</.button>
|
||||
<.input
|
||||
field={@review_form[:comment]}
|
||||
type="textarea"
|
||||
label={gettext("Comment (optional)")}
|
||||
/>
|
||||
<.button class="btn btn-outline">{gettext("Submit review")}</.button>
|
||||
</.form>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -649,36 +712,43 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
/>
|
||||
|
||||
<div class="rounded-3xl bg-neutral p-6 text-neutral-content">
|
||||
<h2 class="text-lg font-bold">Action center</h2>
|
||||
<h2 class="text-lg font-bold">{gettext("Action center")}</h2>
|
||||
<%= cond do %>
|
||||
<% @request.status == :open && @request.requester_id != @current_scope.user.id -> %>
|
||||
<p class="mt-2 text-sm opacity-70">
|
||||
Accept only if you can complete this safely and for free.
|
||||
{gettext("Accept only if you can complete this safely and for free.")}
|
||||
</p>
|
||||
<button phx-click="accept" class="btn btn-success mt-4 w-full">I can help</button>
|
||||
<button phx-click="accept" class="btn btn-success mt-4 w-full">
|
||||
{gettext("I can help")}
|
||||
</button>
|
||||
<% @request.status == :open -> %>
|
||||
<p class="mt-3 text-sm opacity-70">Waiting for a nearby volunteer.</p>
|
||||
<p class="mt-3 text-sm opacity-70">
|
||||
{gettext("Waiting for a nearby volunteer.")}
|
||||
</p>
|
||||
<button
|
||||
:if={Help.requester?(@current_scope, @request)}
|
||||
phx-click="cancel-request"
|
||||
class="btn btn-error btn-outline mt-4 w-full"
|
||||
>
|
||||
Cancel request
|
||||
{gettext("Cancel request")}
|
||||
</button>
|
||||
<% @participant -> %>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="rounded-xl bg-white/10 p-3 text-sm">
|
||||
Helper: {@assignment.helper.display_name}
|
||||
{gettext("Helper: %{name}", name: @assignment.helper.display_name)}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 rounded-xl bg-white/10 p-3 text-center text-xs">
|
||||
<div>
|
||||
<strong class="block text-lg">{@helper_reputation.completed}</strong>completed
|
||||
<strong class="block text-lg">{@helper_reputation.completed}</strong>
|
||||
{gettext("completed")}
|
||||
</div>
|
||||
<div>
|
||||
<strong class="block text-lg">{@helper_reputation.unique_people}</strong>people
|
||||
<strong class="block text-lg">{@helper_reputation.unique_people}</strong>
|
||||
{gettext("people")}
|
||||
</div>
|
||||
<div>
|
||||
<strong class="block text-lg">{@helper_reputation.rating || "—"}</strong>rating
|
||||
<strong class="block text-lg">{@helper_reputation.rating || "—"}</strong>
|
||||
{gettext("rating")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 rounded-xl bg-white/10 p-3 text-xs">
|
||||
|
|
@ -688,8 +758,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
is_nil(@assignment.proximity_observed_at) && "badge-ghost"
|
||||
]}>
|
||||
{if @assignment.proximity_observed_at,
|
||||
do: "proximity supported",
|
||||
else: "no proximity evidence"}
|
||||
do: gettext("proximity supported"),
|
||||
else: gettext("no proximity evidence")}
|
||||
</span>
|
||||
<span class={[
|
||||
"badge badge-sm",
|
||||
|
|
@ -697,11 +767,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
is_nil(@assignment.helper_movement_observed_at) && "badge-ghost"
|
||||
]}>
|
||||
{if @assignment.helper_movement_observed_at,
|
||||
do: "helper movement supported",
|
||||
else: "no movement evidence"}
|
||||
do: gettext("helper movement supported"),
|
||||
else: gettext("no movement evidence")}
|
||||
</span>
|
||||
<p class="w-full opacity-60">
|
||||
Location sharing is optional; missing evidence is not proof of abuse.
|
||||
{gettext(
|
||||
"Location sharing is optional; missing evidence is not proof of abuse."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -716,20 +788,24 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
class="badge badge-warning badge-outline"
|
||||
>
|
||||
{identity.provider} · {if identity.verified_at,
|
||||
do: "verified",
|
||||
else: "unverified"}
|
||||
do: gettext("verified"),
|
||||
else: gettext("unverified")}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
:if={Help.helper?(@current_scope, @assignment) && @assignment.status == :accepted}
|
||||
phx-click="start"
|
||||
class="btn btn-success w-full"
|
||||
>Start helping</button>
|
||||
>
|
||||
{gettext("Start helping")}
|
||||
</button>
|
||||
<button
|
||||
:if={@assignment.status in [:accepted, :in_progress]}
|
||||
phx-click="confirm"
|
||||
class="btn btn-outline w-full"
|
||||
>Confirm my part is complete</button>
|
||||
>
|
||||
{gettext("Confirm my part is complete")}
|
||||
</button>
|
||||
<button
|
||||
:if={
|
||||
Help.helper?(@current_scope, @assignment) &&
|
||||
|
|
@ -738,7 +814,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
phx-click="withdraw"
|
||||
class="btn btn-error btn-outline w-full"
|
||||
>
|
||||
Withdraw from this request
|
||||
{gettext("Withdraw from this request")}
|
||||
</button>
|
||||
<button
|
||||
:if={
|
||||
|
|
@ -748,14 +824,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
phx-click="cancel-request"
|
||||
class="btn btn-error btn-outline w-full"
|
||||
>
|
||||
Cancel request
|
||||
{gettext("Cancel request")}
|
||||
</button>
|
||||
|
||||
<div
|
||||
:if={Help.requester?(@current_scope, @request)}
|
||||
class="rounded-xl bg-white/10 p-4 text-center"
|
||||
>
|
||||
<div class="text-xs opacity-65">HANDOVER CODE FOR THE HELPER</div>
|
||||
<div class="text-xs opacity-65">
|
||||
{gettext("HANDOVER CODE FOR THE HELPER")}
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-3xl font-black tracking-[.25em]">
|
||||
{Help.handover_code(@request.id)}
|
||||
</div>
|
||||
|
|
@ -769,8 +847,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
for={@handover_form}
|
||||
phx-submit="verify-code"
|
||||
>
|
||||
<.input field={@handover_form[:code]} label="Enter handover code" maxlength="6" />
|
||||
<.button class="btn btn-primary mt-2 w-full">Verify handover</.button>
|
||||
<.input
|
||||
field={@handover_form[:code]}
|
||||
label={gettext("Enter handover code")}
|
||||
maxlength="6"
|
||||
/>
|
||||
<.button class="btn btn-primary mt-2 w-full">
|
||||
{gettext("Verify handover")}
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<a
|
||||
|
|
@ -784,11 +868,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
rel="noopener noreferrer nofollow"
|
||||
class="btn btn-outline w-full"
|
||||
>
|
||||
Optional thank-you link
|
||||
{gettext("Optional thank-you link")}
|
||||
</a>
|
||||
</div>
|
||||
<% true -> %>
|
||||
<p class="mt-3 text-sm opacity-70">This request already has a helper.</p>
|
||||
<p class="mt-3 text-sm opacity-70">
|
||||
{gettext("This request already has a helper.")}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
|
@ -796,71 +882,82 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
:if={@participant && @assignment.status in [:accepted, :in_progress]}
|
||||
class="rounded-3xl border border-base-300 p-5"
|
||||
>
|
||||
<h3 class="font-bold">Live location</h3>
|
||||
<h3 class="font-bold">{gettext("Live location")}</h3>
|
||||
<p class="mt-2 text-xs text-base-content/55">
|
||||
In a browser this works only while the page remains open. The Android app shows a
|
||||
persistent notification and can continue after it is minimized. Raw coordinates are
|
||||
removed when sharing stops.
|
||||
{gettext(
|
||||
"In a browser this works only while the page remains open. The Android app shows a persistent notification and can continue after it is minimized. Raw coordinates are removed when sharing stops."
|
||||
)}
|
||||
</p>
|
||||
<div :if={@tracking_active} id="live-tracking" phx-hook="LiveTracking"></div>
|
||||
<button
|
||||
:if={!@tracking_active}
|
||||
phx-click="start-tracking"
|
||||
class="btn btn-outline btn-sm mt-4 w-full"
|
||||
>Share location</button>
|
||||
>
|
||||
{gettext("Share location")}
|
||||
</button>
|
||||
<button
|
||||
:if={@tracking_active}
|
||||
phx-click="stop-tracking"
|
||||
class="btn btn-error btn-sm mt-4 w-full"
|
||||
>Stop and delete position</button>
|
||||
>
|
||||
{gettext("Stop and delete position")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :if={@other_user_id} class="rounded-3xl border border-base-300 p-5">
|
||||
<h3 class="font-bold">Safety controls</h3>
|
||||
<h3 class="font-bold">{gettext("Safety controls")}</h3>
|
||||
<p class="mt-2 text-xs text-base-content/55">
|
||||
Reports go to moderators. Blocking hides requests in both directions and prevents new
|
||||
chat messages.
|
||||
{gettext(
|
||||
"Reports go to moderators. Blocking hides requests in both directions and prevents new chat messages."
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
:if={!@blocked_by_current}
|
||||
phx-click="block-user"
|
||||
class="btn btn-outline btn-sm mt-4 w-full"
|
||||
>
|
||||
Block this user
|
||||
{gettext("Block this user")}
|
||||
</button>
|
||||
<button
|
||||
:if={@blocked_by_current}
|
||||
phx-click="unblock-user"
|
||||
class="btn btn-outline btn-sm mt-4 w-full"
|
||||
>
|
||||
Unblock this user
|
||||
{gettext("Unblock this user")}
|
||||
</button>
|
||||
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2">
|
||||
<div
|
||||
:if={@report_message_id}
|
||||
class="flex items-center justify-between rounded-xl bg-error/10 p-3 text-xs"
|
||||
>
|
||||
<span>The report will be scoped to the selected chat message.</span>
|
||||
<span>{gettext("The report will be scoped to the selected chat message.")}</span>
|
||||
<button type="button" phx-click="clear-report-message" class="btn btn-ghost btn-xs">
|
||||
Clear
|
||||
{gettext("Clear")}
|
||||
</button>
|
||||
</div>
|
||||
<.input
|
||||
field={@report_form[:reason]}
|
||||
type="select"
|
||||
label="Report reason"
|
||||
label={gettext("Report reason")}
|
||||
options={[
|
||||
{"Dangerous request", "dangerous_request"},
|
||||
{"Harassment", "harassment"},
|
||||
{"Fraud", "fraud"},
|
||||
{"Prohibited item", "prohibited_item"},
|
||||
{"Spam", "spam"},
|
||||
{"Impersonation", "impersonation"},
|
||||
{"Other", "other"}
|
||||
{gettext("Dangerous request"), "dangerous_request"},
|
||||
{gettext("Harassment"), "harassment"},
|
||||
{gettext("Fraud"), "fraud"},
|
||||
{gettext("Prohibited item"), "prohibited_item"},
|
||||
{gettext("Spam"), "spam"},
|
||||
{gettext("Impersonation"), "impersonation"},
|
||||
{gettext("Other"), "other"}
|
||||
]}
|
||||
/>
|
||||
<.input field={@report_form[:details]} type="textarea" label="What happened?" />
|
||||
<.button class="btn btn-error btn-outline w-full">Send report</.button>
|
||||
<.input
|
||||
field={@report_form[:details]}
|
||||
type="textarea"
|
||||
label={gettext("What happened?")}
|
||||
/>
|
||||
<.button class="btn btn-error btn-outline w-full">
|
||||
{gettext("Send report")}
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule WhoNeedHelpWeb.UserAuth do
|
||||
use WhoNeedHelpWeb, :verified_routes
|
||||
use Gettext, backend: WhoNeedHelpWeb.Gettext
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
|
@ -174,7 +175,7 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
|||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must re-authenticate to access this page.")
|
||||
|> put_flash(:error, gettext("You must re-authenticate to access this page."))
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|> halt()
|
||||
|
|
@ -204,7 +205,7 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
|||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> put_flash(:error, gettext("You must log in to access this page."))
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|> halt()
|
||||
|
|
@ -220,21 +221,24 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
|||
@doc "Loads the generated session token into LiveView's current scope."
|
||||
def on_mount(:mount_current_scope, _params, session, socket) do
|
||||
scope = live_scope(session)
|
||||
set_live_locale(scope)
|
||||
set_live_locale(scope, session)
|
||||
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
||||
end
|
||||
|
||||
def on_mount(:ensure_authenticated, _params, session, socket) do
|
||||
case live_scope(session) do
|
||||
%Scope{user: %Accounts.User{}} = scope ->
|
||||
set_live_locale(scope)
|
||||
set_live_locale(scope, session)
|
||||
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
||||
|
||||
_ ->
|
||||
socket =
|
||||
socket
|
||||
|> Phoenix.Component.assign(:current_scope, nil)
|
||||
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|
||||
|> Phoenix.LiveView.put_flash(
|
||||
:error,
|
||||
gettext("You must log in to access this page.")
|
||||
)
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
|
||||
|
||||
{:halt, socket}
|
||||
|
|
@ -245,13 +249,13 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
|||
case live_scope(session) do
|
||||
%Scope{user: %Accounts.User{} = user} = scope ->
|
||||
if Accounts.moderator_authorized?(user) do
|
||||
set_live_locale(scope)
|
||||
set_live_locale(scope, session)
|
||||
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
||||
else
|
||||
socket =
|
||||
socket
|
||||
|> Phoenix.Component.assign(:current_scope, scope)
|
||||
|> Phoenix.LiveView.put_flash(:error, "Moderator access is required.")
|
||||
|> Phoenix.LiveView.put_flash(:error, gettext("Moderator access is required."))
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/requests")
|
||||
|
||||
{:halt, socket}
|
||||
|
|
@ -261,7 +265,10 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
|||
socket =
|
||||
socket
|
||||
|> Phoenix.Component.assign(:current_scope, nil)
|
||||
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|
||||
|> Phoenix.LiveView.put_flash(
|
||||
:error,
|
||||
gettext("You must log in to access this page.")
|
||||
)
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
|
||||
|
||||
{:halt, socket}
|
||||
|
|
@ -277,9 +284,14 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
|||
|
||||
defp live_scope(_session), do: nil
|
||||
|
||||
defp set_live_locale(%Scope{user: %{locale: locale}}) when locale in ~w(en uk ru) do
|
||||
defp set_live_locale(_scope, %{"locale" => locale}) when locale in ~w(en uk ru) do
|
||||
Gettext.put_locale(WhoNeedHelpWeb.Gettext, locale)
|
||||
end
|
||||
|
||||
defp set_live_locale(_scope), do: Gettext.put_locale(WhoNeedHelpWeb.Gettext, "en")
|
||||
defp set_live_locale(%Scope{user: %{locale: locale}}, _session) when locale in ~w(en uk ru) do
|
||||
Gettext.put_locale(WhoNeedHelpWeb.Gettext, locale)
|
||||
end
|
||||
|
||||
defp set_live_locale(_scope, _session),
|
||||
do: Gettext.put_locale(WhoNeedHelpWeb.Gettext, "en")
|
||||
end
|
||||
|
|
|
|||
26
lib/who_need_help_web/validation_messages.ex
Normal file
26
lib/who_need_help_web/validation_messages.ex
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
defmodule WhoNeedHelpWeb.ValidationMessages do
|
||||
@moduledoc false
|
||||
|
||||
use Gettext, backend: WhoNeedHelpWeb.Gettext
|
||||
|
||||
def all do
|
||||
[
|
||||
dgettext_noop("errors", "must contain at least one non-empty localized name"),
|
||||
dgettext_noop("errors", "must contain non-empty localized descriptions"),
|
||||
dgettext_noop("errors", "contains an invalid field definition"),
|
||||
dgettext_noop("errors", "field keys must be unique"),
|
||||
dgettext_noop("errors", "must be an object with a fields array"),
|
||||
dgettext_noop("errors", "select exactly one report target"),
|
||||
dgettext_noop("errors", "select a valid location"),
|
||||
dgettext_noop("errors", "must be in the future"),
|
||||
dgettext_noop("errors", "select an activity category"),
|
||||
dgettext_noop("errors", "must be on or before the start time"),
|
||||
dgettext_noop("errors", "must be a complete http(s) URL"),
|
||||
dgettext_noop("errors", "must be a GitHub profile URL"),
|
||||
dgettext_noop("errors", "you must confirm that you are 18+ and accept the rules"),
|
||||
dgettext_noop("errors", "must have the @ sign and no spaces"),
|
||||
dgettext_noop("errors", "did not change"),
|
||||
dgettext_noop("errors", "does not match password")
|
||||
]
|
||||
end
|
||||
end
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -110,3 +110,83 @@ msgstr ""
|
|||
|
||||
msgid "must be equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:10
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "contains an invalid field definition"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "did not change"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "does not match password"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:11
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "field keys must be unique"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a GitHub profile URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete http(s) URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:12
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be an object with a fields array"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:15
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be in the future"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:17
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be on or before the start time"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:8
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must contain at least one non-empty localized name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:9
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must contain non-empty localized descriptions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must have the @ sign and no spaces"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:14
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select a valid location"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:16
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select an activity category"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:13
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select exactly one report target"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "you must confirm that you are 18+ and accept the rules"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -107,3 +107,83 @@ msgstr ""
|
|||
|
||||
msgid "must be equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:10
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "contains an invalid field definition"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "did not change"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "does not match password"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:11
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "field keys must be unique"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a GitHub profile URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete http(s) URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:12
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be an object with a fields array"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:15
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be in the future"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:17
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be on or before the start time"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:8
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must contain at least one non-empty localized name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:9
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must contain non-empty localized descriptions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must have the @ sign and no spaces"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:14
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select a valid location"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:16
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select an activity category"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:13
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select exactly one report target"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "you must confirm that you are 18+ and accept the rules"
|
||||
msgstr ""
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,111 +1,191 @@
|
|||
## "msgid"s in this file come from POT (.pot) files.
|
||||
###
|
||||
### Do not add, change, or remove "msgid"s manually here as
|
||||
### they're tied to the ones in the corresponding POT file
|
||||
### (with the same domain).
|
||||
###
|
||||
### Use "mix gettext.extract --merge" or "mix gettext.merge"
|
||||
### to merge POT files into PO files.
|
||||
# "msgid"s in this file come from POT (.pot) files.
|
||||
# #
|
||||
# # Do not add, change, or remove "msgid"s manually here as
|
||||
# # they're tied to the ones in the corresponding POT file
|
||||
# # (with the same domain).
|
||||
# #
|
||||
# # Use "mix gettext.extract --merge" or "mix gettext.merge"
|
||||
# # to merge POT files into PO files.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: ru\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100 != 11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10||n%100>=20) ? 1 : 2);\n"
|
||||
|
||||
msgid "can't be blank"
|
||||
msgstr ""
|
||||
msgstr "не может быть пустым"
|
||||
|
||||
msgid "has already been taken"
|
||||
msgstr ""
|
||||
msgstr "уже занято"
|
||||
|
||||
msgid "is invalid"
|
||||
msgstr ""
|
||||
msgstr "недопустимо"
|
||||
|
||||
msgid "must be accepted"
|
||||
msgstr ""
|
||||
msgstr "необходимо принять"
|
||||
|
||||
msgid "has invalid format"
|
||||
msgstr ""
|
||||
msgstr "имеет неверный формат"
|
||||
|
||||
msgid "has an invalid entry"
|
||||
msgstr ""
|
||||
msgstr "содержит недопустимое значение"
|
||||
|
||||
msgid "is reserved"
|
||||
msgstr ""
|
||||
msgstr "зарезервировано"
|
||||
|
||||
msgid "does not match confirmation"
|
||||
msgstr ""
|
||||
msgstr "не совпадает с подтверждением"
|
||||
|
||||
msgid "is still associated with this entry"
|
||||
msgstr ""
|
||||
msgstr "всё ещё связано с этой записью"
|
||||
|
||||
msgid "are still associated with this entry"
|
||||
msgstr ""
|
||||
msgstr "всё ещё связаны с этой записью"
|
||||
|
||||
msgid "should have %{count} item(s)"
|
||||
msgid_plural "should have %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "должно содержать %{count} элемент"
|
||||
msgstr[1] "должно содержать %{count} элемента"
|
||||
msgstr[2] "должно содержать %{count} элементов"
|
||||
|
||||
msgid "should be %{count} character(s)"
|
||||
msgid_plural "should be %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "должно содержать %{count} символ"
|
||||
msgstr[1] "должно содержать %{count} символа"
|
||||
msgstr[2] "должно содержать %{count} символов"
|
||||
|
||||
msgid "should be %{count} byte(s)"
|
||||
msgid_plural "should be %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "должно занимать %{count} байт"
|
||||
msgstr[1] "должно занимать %{count} байта"
|
||||
msgstr[2] "должно занимать %{count} байтов"
|
||||
|
||||
msgid "should have at least %{count} item(s)"
|
||||
msgid_plural "should have at least %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "должно содержать не менее %{count} элемента"
|
||||
msgstr[1] "должно содержать не менее %{count} элементов"
|
||||
msgstr[2] "должно содержать не менее %{count} элементов"
|
||||
|
||||
msgid "should be at least %{count} character(s)"
|
||||
msgid_plural "should be at least %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "должно содержать не менее %{count} символа"
|
||||
msgstr[1] "должно содержать не менее %{count} символов"
|
||||
msgstr[2] "должно содержать не менее %{count} символов"
|
||||
|
||||
msgid "should be at least %{count} byte(s)"
|
||||
msgid_plural "should be at least %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "должно занимать не менее %{count} байта"
|
||||
msgstr[1] "должно занимать не менее %{count} байтов"
|
||||
msgstr[2] "должно занимать не менее %{count} байтов"
|
||||
|
||||
msgid "should have at most %{count} item(s)"
|
||||
msgid_plural "should have at most %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "должно содержать не более %{count} элемента"
|
||||
msgstr[1] "должно содержать не более %{count} элементов"
|
||||
msgstr[2] "должно содержать не более %{count} элементов"
|
||||
|
||||
msgid "should be at most %{count} character(s)"
|
||||
msgid_plural "should be at most %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "должно содержать не более %{count} символа"
|
||||
msgstr[1] "должно содержать не более %{count} символов"
|
||||
msgstr[2] "должно содержать не более %{count} символов"
|
||||
|
||||
msgid "should be at most %{count} byte(s)"
|
||||
msgid_plural "should be at most %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "должно занимать не более %{count} байта"
|
||||
msgstr[1] "должно занимать не более %{count} байтов"
|
||||
msgstr[2] "должно занимать не более %{count} байтов"
|
||||
|
||||
msgid "must be less than %{number}"
|
||||
msgstr ""
|
||||
msgstr "должно быть меньше %{number}"
|
||||
|
||||
msgid "must be greater than %{number}"
|
||||
msgstr ""
|
||||
msgstr "должно быть больше %{number}"
|
||||
|
||||
msgid "must be less than or equal to %{number}"
|
||||
msgstr ""
|
||||
msgstr "должно быть не больше %{number}"
|
||||
|
||||
msgid "must be greater than or equal to %{number}"
|
||||
msgstr ""
|
||||
msgstr "должно быть не меньше %{number}"
|
||||
|
||||
msgid "must be equal to %{number}"
|
||||
msgstr ""
|
||||
msgstr "должно быть равно %{number}"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:10
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "contains an invalid field definition"
|
||||
msgstr "содержит неверное определение поля"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "did not change"
|
||||
msgstr "не изменилось"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "does not match password"
|
||||
msgstr "не совпадает с паролем"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:11
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "field keys must be unique"
|
||||
msgstr "ключи полей должны быть уникальными"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a GitHub profile URL"
|
||||
msgstr "должно быть URL профиля GitHub"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete http(s) URL"
|
||||
msgstr "должно быть полным URL с http(s)"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:12
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be an object with a fields array"
|
||||
msgstr "должно быть объектом с массивом fields"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:15
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be in the future"
|
||||
msgstr "должно быть в будущем"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:17
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be on or before the start time"
|
||||
msgstr "должно быть не позже времени начала"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:8
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must contain at least one non-empty localized name"
|
||||
msgstr "должно содержать хотя бы одно непустое локализованное название"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:9
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must contain non-empty localized descriptions"
|
||||
msgstr "должно содержать непустые локализованные описания"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must have the @ sign and no spaces"
|
||||
msgstr "должно содержать знак @ и не содержать пробелов"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:14
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select a valid location"
|
||||
msgstr "выберите допустимую геопозицию"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:16
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select an activity category"
|
||||
msgstr "выберите категорию активности"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:13
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select exactly one report target"
|
||||
msgstr "выберите ровно один объект жалобы"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "you must confirm that you are 18+ and accept the rules"
|
||||
msgstr "подтвердите, что вам исполнилось 18 лет и вы принимаете правила"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,111 +1,191 @@
|
|||
## "msgid"s in this file come from POT (.pot) files.
|
||||
###
|
||||
### Do not add, change, or remove "msgid"s manually here as
|
||||
### they're tied to the ones in the corresponding POT file
|
||||
### (with the same domain).
|
||||
###
|
||||
### Use "mix gettext.extract --merge" or "mix gettext.merge"
|
||||
### to merge POT files into PO files.
|
||||
# "msgid"s in this file come from POT (.pot) files.
|
||||
# #
|
||||
# # Do not add, change, or remove "msgid"s manually here as
|
||||
# # they're tied to the ones in the corresponding POT file
|
||||
# # (with the same domain).
|
||||
# #
|
||||
# # Use "mix gettext.extract --merge" or "mix gettext.merge"
|
||||
# # to merge POT files into PO files.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: uk\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100 != 11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10||n%100>=20) ? 1 : 2);\n"
|
||||
|
||||
msgid "can't be blank"
|
||||
msgstr ""
|
||||
msgstr "не може бути порожнім"
|
||||
|
||||
msgid "has already been taken"
|
||||
msgstr ""
|
||||
msgstr "вже зайнято"
|
||||
|
||||
msgid "is invalid"
|
||||
msgstr ""
|
||||
msgstr "неприпустимо"
|
||||
|
||||
msgid "must be accepted"
|
||||
msgstr ""
|
||||
msgstr "необхідно прийняти"
|
||||
|
||||
msgid "has invalid format"
|
||||
msgstr ""
|
||||
msgstr "має неправильний формат"
|
||||
|
||||
msgid "has an invalid entry"
|
||||
msgstr ""
|
||||
msgstr "містить неприпустиме значення"
|
||||
|
||||
msgid "is reserved"
|
||||
msgstr ""
|
||||
msgstr "зарезервовано"
|
||||
|
||||
msgid "does not match confirmation"
|
||||
msgstr ""
|
||||
msgstr "не збігається з підтвердженням"
|
||||
|
||||
msgid "is still associated with this entry"
|
||||
msgstr ""
|
||||
msgstr "досі пов’язано з цим записом"
|
||||
|
||||
msgid "are still associated with this entry"
|
||||
msgstr ""
|
||||
msgstr "досі пов’язані з цим записом"
|
||||
|
||||
msgid "should have %{count} item(s)"
|
||||
msgid_plural "should have %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "має містити %{count} елемент"
|
||||
msgstr[1] "має містити %{count} елементи"
|
||||
msgstr[2] "має містити %{count} елементів"
|
||||
|
||||
msgid "should be %{count} character(s)"
|
||||
msgid_plural "should be %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "має містити %{count} символ"
|
||||
msgstr[1] "має містити %{count} символи"
|
||||
msgstr[2] "має містити %{count} символів"
|
||||
|
||||
msgid "should be %{count} byte(s)"
|
||||
msgid_plural "should be %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "має займати %{count} байт"
|
||||
msgstr[1] "має займати %{count} байти"
|
||||
msgstr[2] "має займати %{count} байтів"
|
||||
|
||||
msgid "should have at least %{count} item(s)"
|
||||
msgid_plural "should have at least %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "має містити щонайменше %{count} елемент"
|
||||
msgstr[1] "має містити щонайменше %{count} елементи"
|
||||
msgstr[2] "має містити щонайменше %{count} елементів"
|
||||
|
||||
msgid "should be at least %{count} character(s)"
|
||||
msgid_plural "should be at least %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "має містити щонайменше %{count} символ"
|
||||
msgstr[1] "має містити щонайменше %{count} символи"
|
||||
msgstr[2] "має містити щонайменше %{count} символів"
|
||||
|
||||
msgid "should be at least %{count} byte(s)"
|
||||
msgid_plural "should be at least %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "має займати щонайменше %{count} байт"
|
||||
msgstr[1] "має займати щонайменше %{count} байти"
|
||||
msgstr[2] "має займати щонайменше %{count} байтів"
|
||||
|
||||
msgid "should have at most %{count} item(s)"
|
||||
msgid_plural "should have at most %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "має містити не більше %{count} елемента"
|
||||
msgstr[1] "має містити не більше %{count} елементів"
|
||||
msgstr[2] "має містити не більше %{count} елементів"
|
||||
|
||||
msgid "should be at most %{count} character(s)"
|
||||
msgid_plural "should be at most %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "має містити не більше %{count} символу"
|
||||
msgstr[1] "має містити не більше %{count} символів"
|
||||
msgstr[2] "має містити не більше %{count} символів"
|
||||
|
||||
msgid "should be at most %{count} byte(s)"
|
||||
msgid_plural "should be at most %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
msgstr[0] "має займати не більше %{count} байта"
|
||||
msgstr[1] "має займати не більше %{count} байтів"
|
||||
msgstr[2] "має займати не більше %{count} байтів"
|
||||
|
||||
msgid "must be less than %{number}"
|
||||
msgstr ""
|
||||
msgstr "має бути менше за %{number}"
|
||||
|
||||
msgid "must be greater than %{number}"
|
||||
msgstr ""
|
||||
msgstr "має бути більше за %{number}"
|
||||
|
||||
msgid "must be less than or equal to %{number}"
|
||||
msgstr ""
|
||||
msgstr "має бути не більше за %{number}"
|
||||
|
||||
msgid "must be greater than or equal to %{number}"
|
||||
msgstr ""
|
||||
msgstr "має бути не менше за %{number}"
|
||||
|
||||
msgid "must be equal to %{number}"
|
||||
msgstr ""
|
||||
msgstr "має дорівнювати %{number}"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:10
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "contains an invalid field definition"
|
||||
msgstr "містить неправильне визначення поля"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "did not change"
|
||||
msgstr "не змінилося"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "does not match password"
|
||||
msgstr "не збігається з паролем"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:11
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "field keys must be unique"
|
||||
msgstr "ключі полів мають бути унікальними"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a GitHub profile URL"
|
||||
msgstr "має бути URL-адресою профілю GitHub"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete http(s) URL"
|
||||
msgstr "має бути повною URL-адресою з http(s)"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:12
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be an object with a fields array"
|
||||
msgstr "має бути об’єктом із масивом fields"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:15
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be in the future"
|
||||
msgstr "має бути в майбутньому"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:17
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be on or before the start time"
|
||||
msgstr "має бути не пізніше часу початку"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:8
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must contain at least one non-empty localized name"
|
||||
msgstr "має містити щонайменше одну непорожню локалізовану назву"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:9
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must contain non-empty localized descriptions"
|
||||
msgstr "має містити непорожні локалізовані описи"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must have the @ sign and no spaces"
|
||||
msgstr "має містити знак @ і не містити пробілів"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:14
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select a valid location"
|
||||
msgstr "виберіть припустиму геопозицію"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:16
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select an activity category"
|
||||
msgstr "виберіть категорію активності"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:13
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "select exactly one report target"
|
||||
msgstr "виберіть рівно один об’єкт скарги"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "you must confirm that you are 18+ and accept the rules"
|
||||
msgstr "підтвердьте, що вам виповнилося 18 років і ви приймаєте правила"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
defmodule WhoNeedHelp.Repo.Migrations.AddLocalizedCategoryDescriptions do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:categories) do
|
||||
add :descriptions, :map, null: false, default: %{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -14,6 +14,14 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|
|||
assert html_response(conn, 200) =~ "Помощь может быть ближе, чем кажется."
|
||||
end
|
||||
|
||||
test "GET /safety selects Ukrainian locale and keeps it in the session", %{conn: conn} do
|
||||
conn = get(conn, ~p"/safety?locale=uk")
|
||||
assert html_response(conn, 200) =~ "Правила безпеки"
|
||||
|
||||
conn = get(conn, ~p"/")
|
||||
assert html_response(conn, 200) =~ "Допомога може бути ближче, ніж здається."
|
||||
end
|
||||
|
||||
test "GET /safety", %{conn: conn} do
|
||||
conn = get(conn, ~p"/safety")
|
||||
assert html_response(conn, 200) =~ "Safety rules"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
assert html =~ "Repeated help between the same pair"
|
||||
end
|
||||
|
||||
test "authenticated LiveView honors the locale stored by the browser pipeline", %{conn: conn} do
|
||||
conn = get(conn, ~p"/?locale=uk")
|
||||
{:ok, _view, html} = live(conn, ~p"/requests")
|
||||
|
||||
assert html =~ "Хто потребує допомоги?"
|
||||
assert html =~ "Створити термінову заявку"
|
||||
end
|
||||
|
||||
test "new request form is driven by category structured fields", %{conn: conn} do
|
||||
category = Catalog.seed_defaults()
|
||||
{:ok, view, html} = live(conn, ~p"/requests/new")
|
||||
|
|
@ -26,9 +34,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
})
|
||||
|
||||
assert html =~ "Medicine pickup status"
|
||||
assert html =~ "Pickup and deliver a legal medicine"
|
||||
assert html =~ "help_request[structured_data][pickup_status]"
|
||||
end
|
||||
|
||||
test "category descriptions use the current Ukrainian locale", %{conn: conn} do
|
||||
category = Catalog.seed_defaults()
|
||||
conn = get(conn, ~p"/?locale=uk")
|
||||
{:ok, view, _html} = live(conn, ~p"/requests/new")
|
||||
|
||||
html =
|
||||
render_change(view, "validate", %{
|
||||
"help_request" => %{"category_id" => category.id}
|
||||
})
|
||||
|
||||
assert html =~ "Отримати й доставити дозволені ліки"
|
||||
end
|
||||
|
||||
test "new request form renders hierarchical roadside fields and safety copy", %{conn: conn} do
|
||||
Catalog.seed_defaults()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user