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;
|
# public constructors are used. Keep these filters path- and warning-specific;
|
||||||
# `list_unused_filters: true` makes Dialyxir fail when either workaround is no
|
# `list_unused_filters: true` makes Dialyxir fail when either workaround is no
|
||||||
# longer needed.
|
# 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/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.
|
social link, category moderation, report resolution, and account restriction.
|
||||||
The same gate checks keyboard skip navigation, WCAG violations and contrast on
|
The same gate checks keyboard skip navigation, WCAG violations and contrast on
|
||||||
four public pages in both themes, horizontal overflow at three viewport widths,
|
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
|
An optional list of Playwright spec paths can be passed after the command for a
|
||||||
focused diagnostic run.
|
focused diagnostic run.
|
||||||
It retains traces, screenshots, video, and Compose logs under the ignored
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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.
|
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,
|
- The containerized `scripts/quality.sh` gate passes ShellCheck, Hadolint,
|
||||||
actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and
|
actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and
|
||||||
the rendered Kubernetes manifest, compiler/xref/Credo/Sobelow/Dialyzer/Hex
|
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
|
release image. It creates random one-run database credentials and removes its
|
||||||
exact volume, networks, images, and source snapshot.
|
exact volume, networks, images, and source snapshot.
|
||||||
- The checked-in GitHub Actions workflow runs the same isolated backend/security
|
- 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
|
Mailpit, both web replicas, and both worker replicas became Ready as non-root
|
||||||
containers with read-only root filesystems, dropped capabilities, and
|
containers with read-only root filesystems, dropped capabilities, and
|
||||||
RuntimeDefault seccomp where configured. The cross-node PubSub probe passed.
|
RuntimeDefault seccomp where configured. The cross-node PubSub probe passed.
|
||||||
- The remaining rows above are still pending. In particular, complete EN/UK/RU
|
- EN/UK/RU extraction and catalogs are now complete for current product UI and
|
||||||
extraction and catalogs are not implied by the completed accessibility
|
custom validation messages. Locale selection persists for public pages and
|
||||||
checks; this document is not a completion claim.
|
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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
## Reproducible checks
|
||||||
|
|
||||||
- `./scripts/test.sh`: 150 tests, 0 failures after the Android public-staging and
|
- The isolated `./scripts/quality.sh` run completed on 2026-07-19 with 153
|
||||||
realtime tracking-evidence changes
|
tests and 0 failures after the full localization changes
|
||||||
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
|
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
|
||||||
- `mix compile --force --warnings-as-errors` and
|
- `mix compile --force --warnings-as-errors` and
|
||||||
`mix format --check-formatted`: passed against the same final source.
|
`mix format --check-formatted`: passed against the same final source.
|
||||||
- `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning
|
- `./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
|
threshold, actionlint 1.7.12, all four Compose renders, Helm lint, Trivy
|
||||||
source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow
|
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
|
production-image vulnerability scan. The rendered Helm manifest and Debian
|
||||||
13.6 release image each reported zero HIGH/CRITICAL findings under the
|
13.6 release image each reported zero HIGH/CRITICAL findings under the
|
||||||
configured gates.
|
configured gates.
|
||||||
- Dialyzer passed with two exact, documented filters and zero unused filters.
|
- Dialyzer passed with three path- and warning-specific documented filters and
|
||||||
Both findings are opaque-type warnings at Ecto `Multi`/`MapSet` call sites;
|
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
|
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
|
broad pattern. This is not a claim that third-party dependency compilation is
|
||||||
warning-free.
|
warning-free.
|
||||||
|
|
@ -64,13 +66,23 @@ results from product limits and unknown production properties.
|
||||||
containers also used read-only root filesystems and dropped capabilities.
|
containers also used read-only root filesystems and dropped capabilities.
|
||||||
The cross-node probe observed all four BEAM nodes, and live/readiness returned
|
The cross-node probe observed all four BEAM nodes, and live/readiness returned
|
||||||
`ok`/`ready`. Database counts remained `0 users / 0 help requests`.
|
`ok`/`ready`. Database counts remained `0 users / 0 help requests`.
|
||||||
- The committed browser suite passed its 1/1 bootstrap and all 6/6 Chromium
|
- 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.
|
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,
|
It covered axe checks across four pages and two themes, keyboard navigation,
|
||||||
three responsive widths, Activity/moderation/privacy, the two-user medicine
|
three responsive widths, Activity/moderation/privacy, the two-user medicine
|
||||||
handover and blind reviews, public boundaries, local raster maps, and an
|
handover and blind reviews, public boundaries, local raster maps, and an
|
||||||
offline LiveSocket transport failure/reconnect. The uniquely named Compose
|
offline LiveSocket transport failure/reconnect. It also verified persistent
|
||||||
volume and networks were removed.
|
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
|
- Browser verification used headed Chrome. The authenticated matched-request
|
||||||
page rendered its chat, MapLibre marker, and live-location controls with no
|
page rendered its chat, MapLibre marker, and live-location controls with no
|
||||||
console errors or warnings after the foreground-service rollout.
|
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
|
def registration_changeset(user, attrs, opts \\ []) do
|
||||||
user
|
user
|
||||||
|> cast(attrs, [:email, :display_name, :terms_accepted])
|
|> cast(attrs, [:email, :display_name, :terms_accepted, :locale])
|
||||||
|> validate_required([:display_name])
|
|> validate_required([:display_name, :locale])
|
||||||
|
|> validate_inclusion(:locale, ~w(en uk ru))
|
||||||
|> validate_acceptance(:terms_accepted,
|
|> validate_acceptance(:terms_accepted,
|
||||||
message: "you must confirm that you are 18+ and accept the rules"
|
message: "you must confirm that you are 18+ and accept the rules"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
defmodule WhoNeedHelp.Accounts.UserNotifier do
|
defmodule WhoNeedHelp.Accounts.UserNotifier do
|
||||||
|
use Gettext, backend: WhoNeedHelpWeb.Gettext
|
||||||
|
|
||||||
import Swoosh.Email
|
import Swoosh.Email
|
||||||
|
|
||||||
alias WhoNeedHelp.Mailer
|
alias WhoNeedHelp.Mailer
|
||||||
|
|
@ -24,20 +26,17 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
|
||||||
Deliver instructions to update a user email.
|
Deliver instructions to update a user email.
|
||||||
"""
|
"""
|
||||||
def deliver_update_email_instructions(user, url) do
|
def deliver_update_email_instructions(user, url) do
|
||||||
deliver(user.email, "Update email instructions", """
|
with_user_locale(user, fn ->
|
||||||
|
deliver(
|
||||||
==============================
|
user.email,
|
||||||
|
gettext("Update email instructions"),
|
||||||
Hi #{user.email},
|
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.",
|
||||||
You can change your email by visiting the URL below:
|
email: user.email,
|
||||||
|
url: url
|
||||||
#{url}
|
)
|
||||||
|
)
|
||||||
If you didn't request this change, please ignore this.
|
end)
|
||||||
|
|
||||||
==============================
|
|
||||||
""")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -51,36 +50,38 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp deliver_magic_link_instructions(user, url) do
|
defp deliver_magic_link_instructions(user, url) do
|
||||||
deliver(user.email, "Log in instructions", """
|
with_user_locale(user, fn ->
|
||||||
|
deliver(
|
||||||
==============================
|
user.email,
|
||||||
|
gettext("Log in instructions"),
|
||||||
Hi #{user.email},
|
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.",
|
||||||
You can log into your account by visiting the URL below:
|
email: user.email,
|
||||||
|
url: url
|
||||||
#{url}
|
)
|
||||||
|
)
|
||||||
If you didn't request this email, please ignore this.
|
end)
|
||||||
|
|
||||||
==============================
|
|
||||||
""")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp deliver_confirmation_instructions(user, url) do
|
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},
|
defp with_user_locale(_user, fun) do
|
||||||
|
Gettext.with_locale(WhoNeedHelpWeb.Gettext, "en", fun)
|
||||||
You can confirm your account by visiting the URL below:
|
|
||||||
|
|
||||||
#{url}
|
|
||||||
|
|
||||||
If you didn't create an account with us, please ignore this.
|
|
||||||
|
|
||||||
==============================
|
|
||||||
""")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
category_attrs =
|
category_attrs =
|
||||||
category_attrs
|
category_attrs
|
||||||
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|
||||||
|
|> normalize_descriptions()
|
||||||
|> Map.put_new("parent_id", proposal.parent_id)
|
|> Map.put_new("parent_id", proposal.parent_id)
|
||||||
|> Map.put_new("mode", to_string(proposal.mode))
|
|> 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(%{"type" => "boolean"}, "false"), do: false
|
||||||
defp normalize_structured_value(_field, value), do: value
|
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
|
defp seed_category(attrs) do
|
||||||
case Repo.get_by(Category, slug: attrs.slug) do
|
case Repo.get_by(Category, slug: attrs.slug) do
|
||||||
nil ->
|
nil ->
|
||||||
|
|
@ -281,9 +292,9 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{}
|
%{}
|
||||||
|> put_backfill(:names, category.names in [nil, %{}], attrs.names)
|
|> put_backfill(:names, category.names in [nil, %{}], attrs.names)
|
||||||
|> put_backfill(
|
|> put_backfill(
|
||||||
:description,
|
:descriptions,
|
||||||
category.description in [nil, ""],
|
category.descriptions in [nil, %{}],
|
||||||
attrs.description
|
attrs.descriptions
|
||||||
)
|
)
|
||||||
|> put_backfill(
|
|> put_backfill(
|
||||||
:structured_fields,
|
:structured_fields,
|
||||||
|
|
@ -328,7 +339,12 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{
|
%{
|
||||||
slug: "urgent-delivery",
|
slug: "urgent-delivery",
|
||||||
names: names("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,
|
active: false,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
structured_fields: %{}
|
structured_fields: %{}
|
||||||
|
|
@ -337,8 +353,12 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{
|
%{
|
||||||
slug: "medicine-pickup",
|
slug: "medicine-pickup",
|
||||||
names: names("Medicine pickup", "Доставка ліків", "Доставка лекарств"),
|
names: names("Medicine pickup", "Доставка ліків", "Доставка лекарств"),
|
||||||
description:
|
descriptions:
|
||||||
|
names(
|
||||||
"Pickup and deliver a legal medicine that is already purchased or reserved.",
|
"Pickup and deliver a legal medicine that is already purchased or reserved.",
|
||||||
|
"Отримати й доставити дозволені ліки, які вже придбані або зарезервовані.",
|
||||||
|
"Получить и доставить разрешённое лекарство, которое уже куплено или зарезервировано."
|
||||||
|
),
|
||||||
sort_order: 10,
|
sort_order: 10,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
"fields" => [
|
"fields" => [
|
||||||
|
|
@ -361,8 +381,12 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{
|
%{
|
||||||
slug: "roadside-help",
|
slug: "roadside-help",
|
||||||
names: names("Roadside help", "Допомога в дорозі", "Помощь в дороге"),
|
names: names("Roadside help", "Допомога в дорозі", "Помощь в дороге"),
|
||||||
description:
|
descriptions:
|
||||||
|
names(
|
||||||
"Non-emergency roadside assistance after people and vehicles are away from active danger.",
|
"Non-emergency roadside assistance after people and vehicles are away from active danger.",
|
||||||
|
"Неекстрена допомога в дорозі після усунення безпосередньої небезпеки для людей і транспорту.",
|
||||||
|
"Неэкстренная помощь в дороге после устранения непосредственной опасности для людей и транспорта."
|
||||||
|
),
|
||||||
active: false,
|
active: false,
|
||||||
sort_order: 100,
|
sort_order: 100,
|
||||||
structured_fields: %{}
|
structured_fields: %{}
|
||||||
|
|
@ -371,7 +395,12 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{
|
%{
|
||||||
slug: "fuel-delivery",
|
slug: "fuel-delivery",
|
||||||
names: names("Out of fuel", "Закінчилося пальне", "Закончилось топливо"),
|
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,
|
sort_order: 110,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
"fields" => [
|
"fields" => [
|
||||||
|
|
@ -400,8 +429,12 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{
|
%{
|
||||||
slug: "car-wheel-help",
|
slug: "car-wheel-help",
|
||||||
names: names("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.",
|
"Help with a flat tyre, inflation, or fitting an available spare in a secured location.",
|
||||||
|
"Допомога з проколом, підкачуванням або встановленням наявного запасного колеса в безпечному місці.",
|
||||||
|
"Помощь с проколом, подкачкой или установкой имеющегося запасного колеса в безопасном месте."
|
||||||
|
),
|
||||||
sort_order: 120,
|
sort_order: 120,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
"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,
|
sort_order: 130,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
"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.",
|
"Help with a motorcycle chain, tyre, or battery after the vehicle is secured.",
|
||||||
|
"Допомога з ланцюгом, колесом або акумулятором мотоцикла після його переміщення в безпечне місце.",
|
||||||
|
"Помощь с цепью, колесом или аккумулятором мотоцикла после его перемещения в безопасное место."
|
||||||
|
),
|
||||||
sort_order: 140,
|
sort_order: 140,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
"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.",
|
"Non-emergency practical help with a stopped vehicle in a secured location.",
|
||||||
|
"Неекстрена практична допомога із зупиненим транспортом у безпечному місці.",
|
||||||
|
"Неэкстренная практическая помощь с остановившимся транспортом в безопасном месте."
|
||||||
|
),
|
||||||
sort_order: 150,
|
sort_order: 150,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
"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.",
|
"Non-emergency presence or practical support only after immediate danger is handled.",
|
||||||
|
"Неекстрена присутність або практична підтримка лише після усунення безпосередньої небезпеки.",
|
||||||
|
"Неэкстренное присутствие или практическая поддержка только после устранения непосредственной опасности."
|
||||||
|
),
|
||||||
sort_order: 160,
|
sort_order: 160,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
"fields" => [
|
"fields" => [
|
||||||
|
|
@ -630,8 +680,12 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{
|
%{
|
||||||
slug: "social-activity",
|
slug: "social-activity",
|
||||||
names: names("Social activity", "Спільне дозвілля", "Совместный досуг"),
|
names: names("Social activity", "Спільне дозвілля", "Совместный досуг"),
|
||||||
description:
|
descriptions:
|
||||||
|
names(
|
||||||
"Optional social plans with organizer-approved participants and no effect on helper reputation.",
|
"Optional social plans with organizer-approved participants and no effect on helper reputation.",
|
||||||
|
"Необов’язкові спільні плани з учасниками, яких схвалює організатор, без впливу на репутацію помічника.",
|
||||||
|
"Необязательные совместные планы с участниками, которых одобряет организатор, без влияния на репутацию помощника."
|
||||||
|
),
|
||||||
mode: :activity,
|
mode: :activity,
|
||||||
active: false,
|
active: false,
|
||||||
sort_order: 1_000,
|
sort_order: 1_000,
|
||||||
|
|
@ -641,7 +695,12 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{
|
%{
|
||||||
slug: "coffee-meetup",
|
slug: "coffee-meetup",
|
||||||
names: names("Coffee or tea", "Кава або чай", "Кофе или чай"),
|
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,
|
mode: :activity,
|
||||||
sort_order: 1_010,
|
sort_order: 1_010,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
|
|
@ -669,7 +728,12 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{
|
%{
|
||||||
slug: "cinema-meetup",
|
slug: "cinema-meetup",
|
||||||
names: names("Cinema", "Кіно", "Кино"),
|
names: names("Cinema", "Кіно", "Кино"),
|
||||||
description: "Find company for a public cinema screening.",
|
descriptions:
|
||||||
|
names(
|
||||||
|
"Find company for a public cinema screening.",
|
||||||
|
"Знайти компанію для перегляду фільму в кінотеатрі.",
|
||||||
|
"Найти компанию для просмотра фильма в кинотеатре."
|
||||||
|
),
|
||||||
mode: :activity,
|
mode: :activity,
|
||||||
sort_order: 1_020,
|
sort_order: 1_020,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
|
|
@ -687,7 +751,12 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{
|
%{
|
||||||
slug: "city-walk",
|
slug: "city-walk",
|
||||||
names: names("Walk", "Прогулянка", "Прогулка"),
|
names: names("Walk", "Прогулянка", "Прогулка"),
|
||||||
description: "Arrange a walk in a public area.",
|
descriptions:
|
||||||
|
names(
|
||||||
|
"Arrange a walk in a public area.",
|
||||||
|
"Домовитися про прогулянку в публічному місці.",
|
||||||
|
"Договориться о прогулке в публичном месте."
|
||||||
|
),
|
||||||
mode: :activity,
|
mode: :activity,
|
||||||
sort_order: 1_030,
|
sort_order: 1_030,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
|
|
@ -704,7 +773,12 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
%{
|
%{
|
||||||
slug: "hiking-group",
|
slug: "hiking-group",
|
||||||
names: names("Hiking", "Похід", "Поход"),
|
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,
|
mode: :activity,
|
||||||
sort_order: 1_040,
|
sort_order: 1_040,
|
||||||
structured_fields: %{
|
structured_fields: %{
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Catalog.Category do
|
||||||
field :slug, :string
|
field :slug, :string
|
||||||
field :names, :map, default: %{}
|
field :names, :map, default: %{}
|
||||||
field :description, :string
|
field :description, :string
|
||||||
|
field :descriptions, :map, default: %{}
|
||||||
field :mode, Ecto.Enum, values: [:help, :activity], default: :help
|
field :mode, Ecto.Enum, values: [:help, :activity], default: :help
|
||||||
field :active, :boolean, default: true
|
field :active, :boolean, default: true
|
||||||
field :sort_order, :integer, default: 0
|
field :sort_order, :integer, default: 0
|
||||||
|
|
@ -25,6 +26,7 @@ defmodule WhoNeedHelp.Catalog.Category do
|
||||||
:slug,
|
:slug,
|
||||||
:names,
|
:names,
|
||||||
:description,
|
:description,
|
||||||
|
:descriptions,
|
||||||
:mode,
|
:mode,
|
||||||
:active,
|
:active,
|
||||||
:sort_order,
|
:sort_order,
|
||||||
|
|
@ -35,6 +37,7 @@ defmodule WhoNeedHelp.Catalog.Category do
|
||||||
|> validate_format(:slug, ~r/^[a-z0-9-]+$/)
|
|> validate_format(:slug, ~r/^[a-z0-9-]+$/)
|
||||||
|> validate_length(:slug, max: 80)
|
|> validate_length(:slug, max: 80)
|
||||||
|> validate_names()
|
|> validate_names()
|
||||||
|
|> validate_descriptions()
|
||||||
|> validate_structured_fields()
|
|> validate_structured_fields()
|
||||||
|> unique_constraint(:slug)
|
|> unique_constraint(:slug)
|
||||||
end
|
end
|
||||||
|
|
@ -43,6 +46,13 @@ defmodule WhoNeedHelp.Catalog.Category do
|
||||||
names[to_string(locale)] || names["en"] || names |> Map.values() |> List.first() || "Help"
|
names[to_string(locale)] || names["en"] || names |> Map.values() |> List.first() || "Help"
|
||||||
end
|
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
|
defp validate_names(changeset) do
|
||||||
validate_change(changeset, :names, fn :names, names ->
|
validate_change(changeset, :names, fn :names, names ->
|
||||||
if is_map(names) and map_size(names) > 0 and
|
if is_map(names) and map_size(names) > 0 and
|
||||||
|
|
@ -56,6 +66,16 @@ defmodule WhoNeedHelp.Catalog.Category do
|
||||||
end)
|
end)
|
||||||
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
|
defp validate_structured_fields(changeset) do
|
||||||
validate_change(changeset, :structured_fields, fn :structured_fields, schema ->
|
validate_change(changeset, :structured_fields, fn :structured_fields, schema ->
|
||||||
case schema do
|
case schema do
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,35 @@
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<div class="text-sm font-semibold text-success">OPENAI BUILD WEEK</div>
|
<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">
|
<p class="mt-4 text-base-content/65">
|
||||||
Who Need Help was developed with the local Codex CLI authenticated through the
|
{gettext(
|
||||||
builder's ChatGPT subscription. No OpenAI API key or usage-based API fallback is used.
|
"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>
|
</p>
|
||||||
<div class="mt-7 rounded-3xl bg-base-200 p-6">
|
<div class="mt-7 rounded-3xl bg-base-200 p-6">
|
||||||
<div class="text-xs font-bold uppercase tracking-wide text-base-content/50">
|
<div class="text-xs font-bold uppercase tracking-wide text-base-content/50">
|
||||||
Main Codex session ID
|
{gettext("Main Codex session ID")}
|
||||||
</div>
|
</div>
|
||||||
<code class="mt-2 block break-all text-lg">{@session_id}</code>
|
<code class="mt-2 block break-all text-lg">{@session_id}</code>
|
||||||
</div>
|
</div>
|
||||||
<p :if={@session_id == "not-configured"} class="alert alert-warning mt-5 text-sm">
|
<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>
|
</p>
|
||||||
<div class="mt-8 space-y-3 text-sm">
|
<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>
|
<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>
|
</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>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@
|
||||||
{gettext("Help can be closer than you think.")}
|
{gettext("Help can be closer than you think.")}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-6 max-w-2xl text-lg leading-8 text-base-content/70">
|
<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
|
{gettext(
|
||||||
roadside assistance with nearby volunteers who can help for free.
|
"Who Need Help connects people who urgently need medicine pickup or safe roadside assistance with nearby volunteers who can help for free."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-8 flex flex-wrap gap-3">
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
<.link navigate={~p"/requests"} class="btn btn-primary btn-lg">
|
<.link navigate={~p"/requests"} class="btn btn-primary btn-lg">
|
||||||
|
|
@ -26,7 +27,9 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-5 text-sm text-base-content/70">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -34,19 +37,23 @@
|
||||||
<div class="absolute inset-0 rounded-[2rem] bg-success/10 blur-2xl"></div>
|
<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="relative rounded-[2rem] border border-base-300 bg-base-100 p-7 shadow-xl">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<span class="text-sm font-semibold text-success">Medicine pickup</span>
|
<span class="text-sm font-semibold text-success">{gettext("Medicine pickup")}</span>
|
||||||
<span class="badge badge-error badge-sm">urgent</span>
|
<span class="badge badge-error badge-sm">{gettext("urgent")}</span>
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
<div class="mt-7 flex items-center justify-between border-t border-base-300 pt-5">
|
<div class="mt-7 flex items-center justify-between border-t border-base-300 pt-5">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-semibold">Approximate area</div>
|
<div class="text-sm font-semibold">{gettext("Approximate area")}</div>
|
||||||
<div class="text-xs text-base-content/55">Exact address after matching</div>
|
<div class="text-xs text-base-content/55">
|
||||||
|
{gettext("Exact address after matching")}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -55,23 +62,23 @@
|
||||||
<section class="grid gap-4 py-10 md:grid-cols-3">
|
<section class="grid gap-4 py-10 md:grid-cols-3">
|
||||||
<div class="rounded-2xl bg-base-200 p-6">
|
<div class="rounded-2xl bg-base-200 p-6">
|
||||||
<div class="text-3xl">1</div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl bg-base-200 p-6">
|
<div class="rounded-2xl bg-base-200 p-6">
|
||||||
<div class="text-3xl">2</div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl bg-base-200 p-6">
|
<div class="rounded-2xl bg-base-200 p-6">
|
||||||
<div class="text-3xl">3</div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,76 @@
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
<article class="mx-auto max-w-4xl">
|
<article class="mx-auto max-w-4xl">
|
||||||
<div class="text-sm font-semibold text-warning">READ BEFORE USING THE SERVICE</div>
|
<div class="text-sm font-semibold text-warning">
|
||||||
<h1 class="mt-2 text-4xl font-black">Safety rules</h1>
|
{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">
|
<p class="mt-4 text-base-content/65">
|
||||||
Who Need Help coordinates voluntary help between adults. It cannot verify every person,
|
{gettext(
|
||||||
request, item, route, or outcome and cannot guarantee safety.
|
"Who Need Help coordinates voluntary help between adults. It cannot verify every person, request, item, route, or outcome and cannot guarantee safety."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-8 grid gap-5 md:grid-cols-2">
|
<div class="mt-8 grid gap-5 md:grid-cols-2">
|
||||||
<section class="rounded-3xl border border-error/30 bg-error/5 p-6">
|
<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">
|
<p class="mt-2 text-sm leading-6">
|
||||||
Do not wait for a volunteer when there is immediate danger, a serious medical situation,
|
{gettext(
|
||||||
fire, violence, or a crime in progress. Contact the emergency service for your location.
|
"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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-3xl border border-base-300 p-6">
|
<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">
|
<p class="mt-2 text-sm leading-6">
|
||||||
Only request pickup of a legal item already reserved or paid for. Do not request medical
|
{gettext(
|
||||||
advice, a prescription, controlled substances, cash advances, repackaging, or a purchase
|
"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."
|
||||||
on your behalf. Follow pharmacy rules and local law.
|
)}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-3xl border border-info/30 bg-info/5 p-6">
|
<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">
|
<p class="mt-2 text-sm leading-6">
|
||||||
Activities are optional plans, not urgent help, and do not affect helper reputation.
|
{gettext(
|
||||||
Meet in a suitable public place first. Organizers approve every participant; exact
|
"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."
|
||||||
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-3xl border border-base-300 p-6">
|
<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">
|
<p class="mt-2 text-sm leading-6">
|
||||||
Help is voluntary and has no required fee. An external thank-you link is optional and
|
{gettext(
|
||||||
appears only after completion. The platform does not receive, split, refund, guarantee,
|
"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."
|
||||||
or report that transfer.
|
)}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-3xl border border-base-300 p-6">
|
<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">
|
<p class="mt-2 text-sm leading-6">
|
||||||
Public location can be hidden, approximate, or explicitly exact. Live tracking is
|
{gettext(
|
||||||
optional. A browser shares only while its page is open; Android shows a persistent
|
"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."
|
||||||
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-3xl border border-base-300 p-6">
|
<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">
|
<p class="mt-2 text-sm leading-6">
|
||||||
Email confirmation and social links are not identity guarantees. Manually attached social
|
{gettext(
|
||||||
links are marked unverified. Check the person's history, unique interactions, handover
|
"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."
|
||||||
evidence, and revealed reviews, and meet only where you feel safe.
|
)}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-3xl border border-base-300 p-6">
|
<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">
|
<p class="mt-2 text-sm leading-6">
|
||||||
Block a person to hide discovery in both directions and stop new chat. Report dangerous,
|
{gettext(
|
||||||
fraudulent, prohibited, impersonating, harassing, or spam content. Automated signals ask
|
"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."
|
||||||
for human review and are not proof by themselves.
|
)}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,14 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
|
||||||
|> redirect(external: url)
|
|> redirect(external: url)
|
||||||
else
|
else
|
||||||
false ->
|
false ->
|
||||||
oauth_error(conn, "That verification provider is not configured.")
|
oauth_error(conn, gettext("That verification provider is not configured."))
|
||||||
|
|
||||||
{:error, :unsupported_provider} ->
|
{:error, :unsupported_provider} ->
|
||||||
oauth_error(conn, "That verification provider is not supported.")
|
oauth_error(conn, gettext("That verification provider is not supported."))
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
Logger.warning("Unable to start social OAuth (#{error_name(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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -48,21 +48,34 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> delete_session(session_key(provider))
|
|> delete_session(session_key(provider))
|
||||||
|> put_flash(:info, "GitHub profile verified.")
|
|> put_flash(:info, gettext("GitHub profile verified."))
|
||||||
|> redirect(to: ~p"/profile")
|
|> redirect(to: ~p"/profile")
|
||||||
else
|
else
|
||||||
nil ->
|
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 ->
|
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} ->
|
{: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} ->
|
{:error, error} ->
|
||||||
Logger.warning("Social OAuth callback failed (#{error_name(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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
||||||
conn
|
conn
|
||||||
|> put_flash(
|
|> put_flash(
|
||||||
:info,
|
: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")
|
|> redirect(to: ~p"/users/log-in")
|
||||||
|
|
||||||
|
|
@ -37,7 +40,10 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
||||||
{:error, :rate_limited} ->
|
{:error, :rate_limited} ->
|
||||||
conn
|
conn
|
||||||
|> put_status(:too_many_requests)
|
|> 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,
|
|> render(:new,
|
||||||
changeset: Accounts.change_user_registration(%User{}, user_params)
|
changeset: Accounts.change_user_registration(%User{}, user_params)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,27 @@
|
||||||
<div class="mx-auto max-w-sm">
|
<div class="mx-auto max-w-sm">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<.header>
|
<.header>
|
||||||
Register for an account
|
{gettext("Register for an account")}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
Already registered?
|
{gettext("Already registered?")}
|
||||||
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
|
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
|
||||||
Log in
|
{gettext("Log in")}
|
||||||
</.link>
|
</.link>
|
||||||
to your account now.
|
{gettext("to your account now.")}
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
</.header>
|
</.header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.form :let={f} for={@changeset} action={~p"/users/register"}>
|
<.form :let={f} for={@changeset} action={~p"/users/register"}>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={f[:locale].name}
|
||||||
|
value={Gettext.get_locale(WhoNeedHelpWeb.Gettext)}
|
||||||
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={f[:email]}
|
field={f[:email]}
|
||||||
type="email"
|
type="email"
|
||||||
label="Email"
|
label={gettext("Email")}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
required
|
required
|
||||||
|
|
@ -27,30 +32,32 @@
|
||||||
<.input
|
<.input
|
||||||
field={f[:display_name]}
|
field={f[:display_name]}
|
||||||
type="text"
|
type="text"
|
||||||
label="Display name"
|
label={gettext("Display name")}
|
||||||
autocomplete="name"
|
autocomplete="name"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p class="mt-2 text-xs text-base-content/60">
|
<p class="mt-2 text-xs text-base-content/60">
|
||||||
Read the <.link href={~p"/safety"} class="link font-semibold">safety rules</.link>
|
{gettext("Read the")}
|
||||||
before confirming.
|
<.link href={~p"/safety"} class="link font-semibold">{gettext("safety rules")}</.link>
|
||||||
|
{gettext("before confirming.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
field={f[:terms_accepted]}
|
field={f[:terms_accepted]}
|
||||||
type="checkbox"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p class="my-4 text-xs text-base-content/60">
|
<p class="my-4 text-xs text-base-content/60">
|
||||||
Who Need Help is not an emergency or medical service. Medicine requests are limited
|
{gettext(
|
||||||
to pickup of legal items already purchased or reserved.
|
"Who Need Help is not an emergency or medical service. Medicine requests are limited to pickup of legal items already purchased or reserved."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
|
<.button phx-disable-with={gettext("Creating account...")} class="btn btn-primary w-full">
|
||||||
Create an account
|
{gettext("Create an account")}
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
||||||
def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
|
def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
|
||||||
info =
|
info =
|
||||||
case params do
|
case params do
|
||||||
%{"_action" => "confirmed"} -> "User confirmed successfully."
|
%{"_action" => "confirmed"} -> gettext("User confirmed successfully.")
|
||||||
_ -> "Welcome back!"
|
_ -> gettext("Welcome back!")
|
||||||
end
|
end
|
||||||
|
|
||||||
case Accounts.login_user_by_magic_link(token) do
|
case Accounts.login_user_by_magic_link(token) do
|
||||||
|
|
@ -28,7 +28,7 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
||||||
|
|
||||||
{:error, :not_found} ->
|
{:error, :not_found} ->
|
||||||
conn
|
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"))
|
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -37,14 +37,14 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
||||||
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
|
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
|
||||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, "Welcome back!")
|
|> put_flash(:info, gettext("Welcome back!"))
|
||||||
|> UserAuth.log_in_user(user, user_params)
|
|> UserAuth.log_in_user(user, user_params)
|
||||||
else
|
else
|
||||||
form = Phoenix.Component.to_form(user_params, as: "user")
|
form = Phoenix.Component.to_form(user_params, as: "user")
|
||||||
|
|
||||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||||
conn
|
conn
|
||||||
|> put_flash(:error, "Invalid email or password")
|
|> put_flash(:error, gettext("Invalid email or password"))
|
||||||
|> render(:new, form: form)
|
|> render(:new, form: form)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -63,7 +63,9 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
||||||
end
|
end
|
||||||
|
|
||||||
info =
|
info =
|
||||||
|
gettext(
|
||||||
"If your email is in our system, you will receive instructions for logging in shortly."
|
"If your email is in our system, you will receive instructions for logging in shortly."
|
||||||
|
)
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, info)
|
|> put_flash(:info, info)
|
||||||
|
|
@ -72,7 +74,10 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
||||||
{:error, :rate_limited} ->
|
{:error, :rate_limited} ->
|
||||||
conn
|
conn
|
||||||
|> put_status(:too_many_requests)
|
|> 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"))
|
|> render(:new, form: Phoenix.Component.to_form(%{"email" => email}, as: "user"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -87,14 +92,14 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
||||||
|> render(:confirm)
|
|> render(:confirm)
|
||||||
else
|
else
|
||||||
conn
|
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")
|
|> redirect(to: ~p"/users/log-in")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete(conn, _params) do
|
def delete(conn, _params) do
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, "Logged out successfully.")
|
|> put_flash(:info, gettext("Logged out successfully."))
|
||||||
|> UserAuth.log_out_user()
|
|> UserAuth.log_out_user()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
<div class="mx-auto max-w-sm">
|
<div class="mx-auto max-w-sm">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<.header>Welcome {@user.email}</.header>
|
<.header>{gettext("Welcome %{email}", email: @user.email)}</.header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.form
|
<.form
|
||||||
|
|
@ -15,13 +15,16 @@
|
||||||
<.button
|
<.button
|
||||||
name={@form[:remember_me].name}
|
name={@form[:remember_me].name}
|
||||||
value="true"
|
value="true"
|
||||||
phx-disable-with="Confirming..."
|
phx-disable-with={gettext("Confirming...")}
|
||||||
class="btn btn-primary w-full"
|
class="btn btn-primary w-full"
|
||||||
>
|
>
|
||||||
Confirm and stay logged in
|
{gettext("Confirm and stay logged in")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
|
<.button
|
||||||
Confirm and log in only this time
|
phx-disable-with={gettext("Confirming...")}
|
||||||
|
class="btn btn-primary btn-soft w-full mt-2"
|
||||||
|
>
|
||||||
|
{gettext("Confirm and log in only this time")}
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
|
|
@ -34,26 +37,33 @@
|
||||||
>
|
>
|
||||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||||
<%= if @current_scope do %>
|
<%= if @current_scope do %>
|
||||||
<.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
|
<.button
|
||||||
Log in
|
variant="primary"
|
||||||
|
phx-disable-with={gettext("Logging in...")}
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{gettext("Log in")}
|
||||||
</.button>
|
</.button>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.button
|
<.button
|
||||||
name={@form[:remember_me].name}
|
name={@form[:remember_me].name}
|
||||||
value="true"
|
value="true"
|
||||||
phx-disable-with="Logging in..."
|
phx-disable-with={gettext("Logging in...")}
|
||||||
class="btn btn-primary w-full"
|
class="btn btn-primary w-full"
|
||||||
>
|
>
|
||||||
Keep me logged in on this device
|
{gettext("Keep me logged in on this device")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
|
<.button
|
||||||
Log me in only this time
|
phx-disable-with={gettext("Logging in...")}
|
||||||
|
class="btn btn-primary btn-soft w-full mt-2"
|
||||||
|
>
|
||||||
|
{gettext("Log me in only this time")}
|
||||||
</.button>
|
</.button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
<p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,18 @@
|
||||||
<div class="mx-auto max-w-sm space-y-4">
|
<div class="mx-auto max-w-sm space-y-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<.header>
|
<.header>
|
||||||
<p>Log in</p>
|
<p>{gettext("Log in")}</p>
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
<%= if @current_scope do %>
|
<%= 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 %>
|
<% else %>
|
||||||
Don't have an account? <.link
|
{gettext("Don't have an account?")}
|
||||||
|
<.link
|
||||||
navigate={~p"/users/register"}
|
navigate={~p"/users/register"}
|
||||||
class="font-semibold text-brand hover:underline"
|
class="font-semibold text-brand hover:underline"
|
||||||
phx-no-format
|
phx-no-format
|
||||||
>Sign up</.link> for an account now.
|
>{gettext("Sign up")}</.link>
|
||||||
|
{gettext("for an account now.")}
|
||||||
<% end %>
|
<% end %>
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
@ -20,9 +22,10 @@
|
||||||
<div :if={local_mail_adapter?()} class="alert alert-info">
|
<div :if={local_mail_adapter?()} class="alert alert-info">
|
||||||
<.icon name="hero-information-circle" class="size-6 shrink-0" />
|
<.icon name="hero-information-circle" class="size-6 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p>You are running the local mail adapter.</p>
|
<p>{gettext("You are running the local mail adapter.")}</p>
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,25 +35,25 @@
|
||||||
readonly={!!@current_scope}
|
readonly={!!@current_scope}
|
||||||
field={f[:email]}
|
field={f[:email]}
|
||||||
type="email"
|
type="email"
|
||||||
label="Email"
|
label={gettext("Email")}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
required
|
required
|
||||||
phx-mounted={JS.focus()}
|
phx-mounted={JS.focus()}
|
||||||
/>
|
/>
|
||||||
<.button class="btn btn-primary w-full">
|
<.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>
|
</.button>
|
||||||
</.form>
|
</.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"}>
|
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
|
||||||
<.input
|
<.input
|
||||||
readonly={!!@current_scope}
|
readonly={!!@current_scope}
|
||||||
field={f[:email]}
|
field={f[:email]}
|
||||||
type="email"
|
type="email"
|
||||||
label="Email"
|
label={gettext("Email")}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
required
|
required
|
||||||
|
|
@ -58,15 +61,15 @@
|
||||||
<.input
|
<.input
|
||||||
field={f[:password]}
|
field={f[:password]}
|
||||||
type="password"
|
type="password"
|
||||||
label="Password"
|
label={gettext("Password")}
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
/>
|
/>
|
||||||
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
|
<.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>
|
||||||
<.button class="btn btn-primary btn-soft w-full mt-2">
|
<.button class="btn btn-primary btn-soft w-full mt-2">
|
||||||
Log in only this time
|
{gettext("Log in only this time")}
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
||||||
conn
|
conn
|
||||||
|> put_flash(
|
|> put_flash(
|
||||||
:info,
|
: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")
|
|> redirect(to: ~p"/users/settings")
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
||||||
case Accounts.update_user_password(user, user_params) do
|
case Accounts.update_user_password(user, user_params) do
|
||||||
{:ok, {user, _}} ->
|
{:ok, {user, _}} ->
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, "Password updated successfully.")
|
|> put_flash(:info, gettext("Password updated successfully."))
|
||||||
|> put_session(:user_return_to, ~p"/users/settings")
|
|> put_session(:user_return_to, ~p"/users/settings")
|
||||||
|> UserAuth.log_in_user(user)
|
|> 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
|
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
|
||||||
{:ok, _user} ->
|
{:ok, _user} ->
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, "Email changed successfully.")
|
|> put_flash(:info, gettext("Email changed successfully."))
|
||||||
|> redirect(to: ~p"/users/settings")
|
|> redirect(to: ~p"/users/settings")
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
conn
|
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")
|
|> redirect(to: ~p"/users/settings")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<.header>
|
<.header>
|
||||||
Account Settings
|
{gettext("Account Settings")}
|
||||||
<:subtitle>Manage your account email address and password settings</:subtitle>
|
<:subtitle>{gettext("Manage your account email address and password settings")}</:subtitle>
|
||||||
</.header>
|
</.header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -12,13 +12,15 @@
|
||||||
<.input
|
<.input
|
||||||
field={f[:email]}
|
field={f[:email]}
|
||||||
type="email"
|
type="email"
|
||||||
label="Email"
|
label={gettext("Email")}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
|
<.button variant="primary" phx-disable-with={gettext("Changing...")}>
|
||||||
|
{gettext("Change Email")}
|
||||||
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
@ -29,7 +31,7 @@
|
||||||
<.input
|
<.input
|
||||||
field={f[:password]}
|
field={f[:password]}
|
||||||
type="password"
|
type="password"
|
||||||
label="New password"
|
label={gettext("New password")}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
required
|
required
|
||||||
|
|
@ -37,13 +39,13 @@
|
||||||
<.input
|
<.input
|
||||||
field={f[:password_confirmation]}
|
field={f[:password_confirmation]}
|
||||||
type="password"
|
type="password"
|
||||||
label="Confirm new password"
|
label={gettext("Confirm new password")}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<.button variant="primary" phx-disable-with="Changing...">
|
<.button variant="primary" phx-disable-with={gettext("Changing...")}>
|
||||||
Save Password
|
{gettext("Save Password")}
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
Activities.list_open_activities(socket.assigns.current_scope, socket.assigns.filters)
|
Activities.list_open_activities(socket.assigns.current_scope, socket.assigns.filters)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Activities")
|
|> assign(:page_title, gettext("Activities"))
|
||||||
|> assign(:activities, activities)
|
|> assign(:activities, activities)
|
||||||
|> assign(:my_activities, Activities.list_my_activities(socket.assigns.current_scope))
|
|> assign(:my_activities, Activities.list_my_activities(socket.assigns.current_scope))
|
||||||
|> assign(:categories, Catalog.list_categories(:activity))
|
|> assign(:categories, Catalog.list_categories(:activity))
|
||||||
|
|
@ -52,25 +52,38 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
end
|
end
|
||||||
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<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 class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-semibold text-info">OPTIONAL SOCIAL PLANS</div>
|
<div class="text-sm font-semibold text-info">{gettext("OPTIONAL SOCIAL PLANS")}</div>
|
||||||
<h1 class="mt-1 text-4xl font-black tracking-tight">Find people to join</h1>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="alert alert-info mt-5 text-sm">
|
<div class="alert alert-info mt-5 text-sm">
|
||||||
<.icon name="hero-shield-check" class="size-5" />
|
<.icon name="hero-shield-check" class="size-5" />
|
||||||
<span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -82,12 +95,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
<.input
|
<.input
|
||||||
field={@filter_form[:category_id]}
|
field={@filter_form[:category_id]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Activity type"
|
label={gettext("Activity type")}
|
||||||
options={[
|
options={[
|
||||||
{"All activities", ""}
|
{gettext("All activities"), ""}
|
||||||
| Enum.map(
|
| Enum.map(
|
||||||
@categories,
|
@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 == []}
|
:if={@activities == []}
|
||||||
class="rounded-3xl border border-dashed border-base-300 p-12 text-center"
|
class="rounded-3xl border border-dashed border-base-300 p-12 text-center"
|
||||||
>
|
>
|
||||||
<h2 class="text-xl font-bold">No open activities yet</h2>
|
<h2 class="text-xl font-bold">{gettext("No open activities yet")}</h2>
|
||||||
<p class="mt-2 text-base-content/60">Create a clear plan in a public place.</p>
|
<p class="mt-2 text-base-content/60">
|
||||||
|
{gettext("Create a clear plan in a public place.")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.link
|
<.link
|
||||||
|
|
@ -112,7 +127,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-3 flex flex-wrap gap-2">
|
<div class="mb-3 flex flex-wrap gap-2">
|
||||||
<span class="badge badge-info badge-outline">
|
<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>
|
||||||
<span class="badge badge-outline">
|
<span class="badge badge-outline">
|
||||||
{Enum.count(activity.participants, &(&1.status == :approved))}/{activity.capacity}
|
{Enum.count(activity.participants, &(&1.status == :approved))}/{activity.capacity}
|
||||||
|
|
@ -125,8 +143,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
</div>
|
</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">
|
<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>📍 {activity.location_label}</span>
|
||||||
<span>🕒 {Calendar.strftime(activity.starts_at, "%d %b, %H:%M UTC")}</span>
|
<span>🕒 {Calendar.strftime(activity.starts_at, "%d.%m.%Y, %H:%M UTC")}</span>
|
||||||
<span>by {activity.creator.display_name || "Community member"}</span>
|
<span>
|
||||||
|
{gettext("by %{name}",
|
||||||
|
name: activity.creator.display_name || gettext("Community member")
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -143,14 +165,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section :if={@my_activities != []} class="mt-12">
|
<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">
|
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
<.link
|
<.link
|
||||||
:for={activity <- @my_activities}
|
:for={activity <- @my_activities}
|
||||||
navigate={~p"/activities/#{activity.id}"}
|
navigate={~p"/activities/#{activity.id}"}
|
||||||
class="rounded-2xl bg-base-200 p-4"
|
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>
|
<div class="mt-2 font-semibold">{activity.title}</div>
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Create activity")
|
|> assign(:page_title, gettext("Create activity"))
|
||||||
|> assign(:categories, Catalog.list_categories(:activity))
|
|> assign(:categories, Catalog.list_categories(:activity))
|
||||||
|> assign(:selected_category, nil)
|
|> assign(:selected_category, nil)
|
||||||
|> assign(:structured_fields, [])
|
|> assign(:structured_fields, [])
|
||||||
|
|
@ -33,7 +33,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
{:ok, activity} ->
|
{:ok, activity} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
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}")}
|
|> push_navigate(to: ~p"/activities/#{activity.id}")}
|
||||||
|
|
||||||
{:error, %Ecto.Changeset{} = changeset} ->
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
|
@ -94,10 +94,13 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp error_message(:account_not_eligible),
|
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(:rate_limited),
|
||||||
defp error_message(reason), do: "Could not publish the activity: #{inspect(reason)}"
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
|
|
@ -105,17 +108,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
|
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
|
||||||
← Back to activities
|
{gettext("← Back to activities")}
|
||||||
</.link>
|
</.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">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="alert alert-warning mt-6 text-sm">
|
<div class="alert alert-warning mt-6 text-sm">
|
||||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
<span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -129,12 +134,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
<.input
|
<.input
|
||||||
field={@form[:category_id]}
|
field={@form[:category_id]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Activity type"
|
label={gettext("Activity type")}
|
||||||
prompt="Choose activity"
|
prompt={gettext("Choose activity")}
|
||||||
options={
|
options={
|
||||||
Enum.map(
|
Enum.map(
|
||||||
@categories,
|
@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"
|
id="selected-activity-category-description"
|
||||||
class="-mt-3 text-sm text-base-content/60"
|
class="-mt-3 text-sm text-base-content/60"
|
||||||
>
|
>
|
||||||
{@selected_category.description}
|
{Catalog.Category.description(
|
||||||
|
@selected_category,
|
||||||
|
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
:if={@structured_fields != []}
|
:if={@structured_fields != []}
|
||||||
class="space-y-4 rounded-2xl border border-info/30 bg-info/5 p-5"
|
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}>
|
<div :for={field <- @structured_fields}>
|
||||||
<label class="fieldset">
|
<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
|
<select
|
||||||
:if={field["type"] == "select"}
|
:if={field["type"] == "select"}
|
||||||
name={"activity[structured_data][#{field["key"]}]"}
|
name={"activity[structured_data][#{field["key"]}]"}
|
||||||
required={field["required"] == true}
|
required={field["required"] == true}
|
||||||
class="select w-full"
|
class="select w-full"
|
||||||
>
|
>
|
||||||
<option value="">Choose…</option>
|
<option value="">{gettext("Choose…")}</option>
|
||||||
<option
|
<option
|
||||||
:for={option <- field["options"] || []}
|
:for={option <- field["options"] || []}
|
||||||
value={option_value(option)}
|
value={option_value(option)}
|
||||||
|
|
@ -169,7 +179,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
to_string(option_value(option))
|
to_string(option_value(option))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{option_label(option, @current_scope.user.locale)}
|
{option_label(option, Gettext.get_locale(WhoNeedHelpWeb.Gettext))}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
|
|
@ -187,12 +197,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
required={field["required"] == true}
|
required={field["required"] == true}
|
||||||
class="select w-full"
|
class="select w-full"
|
||||||
>
|
>
|
||||||
<option value="">Choose…</option>
|
<option value="">{gettext("Choose…")}</option>
|
||||||
<option value="true" selected={structured_value(@form, field["key"]) == "true"}>
|
<option value="true" selected={structured_value(@form, field["key"]) == "true"}>
|
||||||
Yes
|
{gettext("Yes")}
|
||||||
</option>
|
</option>
|
||||||
<option value="false" selected={structured_value(@form, field["key"]) == "false"}>
|
<option value="false" selected={structured_value(@form, field["key"]) == "false"}>
|
||||||
No
|
{gettext("No")}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -202,34 +212,49 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<.input
|
||||||
field={@form[:description]}
|
field={@form[:description]}
|
||||||
type="textarea"
|
type="textarea"
|
||||||
label="Plan and expectations"
|
label={gettext("Plan and expectations")}
|
||||||
placeholder="Describe the public plan, approximate duration, and who might enjoy it."
|
placeholder={
|
||||||
|
gettext("Describe the public plan, approximate duration, and who might enjoy it.")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<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
|
<.input
|
||||||
field={@form[:join_deadline]}
|
field={@form[:join_deadline]}
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
label="Join requests close (UTC)"
|
label={gettext("Join requests close (UTC)")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<.input
|
||||||
field={@form[:location_label]}
|
field={@form[:location_label]}
|
||||||
label="Approximate public meeting area"
|
label={gettext("Approximate public meeting area")}
|
||||||
placeholder="City centre, near the main entrance"
|
placeholder={gettext("City centre, near the main entrance")}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:location_visibility]}
|
field={@form[:location_visibility]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Public map visibility"
|
label={gettext("Public map visibility")}
|
||||||
options={[
|
options={[
|
||||||
{"Approximate area", "approximate_public"},
|
{gettext("Approximate area"), "approximate_public"},
|
||||||
{"Hidden", "hidden"}
|
{gettext("Hidden"), "hidden"}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div class="rounded-2xl bg-base-200 p-5">
|
<div class="rounded-2xl bg-base-200 p-5">
|
||||||
|
|
@ -239,10 +264,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
id="activity-location-picker"
|
id="activity-location-picker"
|
||||||
class="btn btn-outline btn-sm"
|
class="btn btn-outline btn-sm"
|
||||||
>
|
>
|
||||||
Use my current foreground location
|
{gettext("Use my current foreground location")}
|
||||||
</button>
|
</button>
|
||||||
<p class="mt-2 text-xs text-base-content/60">
|
<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>
|
</p>
|
||||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -250,25 +275,30 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
id="activity-latitude"
|
id="activity-latitude"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
label="Latitude"
|
label={gettext("Latitude")}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:longitude]}
|
field={@form[:longitude]}
|
||||||
id="activity-longitude"
|
id="activity-longitude"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
label="Longitude"
|
label={gettext("Longitude")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-start gap-3 rounded-2xl border border-base-300 p-4 text-sm">
|
<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" />
|
<input type="checkbox" required class="checkbox checkbox-info mt-0.5" />
|
||||||
<span>
|
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<.button class="btn btn-info btn-lg w-full" phx-disable-with="Publishing…">
|
<.button
|
||||||
Publish activity
|
class="btn btn-info btn-lg w-full"
|
||||||
|
phx-disable-with={gettext("Publishing…")}
|
||||||
|
>
|
||||||
|
{gettext("Publish activity")}
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
{:error, _reason} ->
|
{:error, _reason} ->
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, "Activity is unavailable.")
|
|> put_flash(:error, gettext("Activity is unavailable."))
|
||||||
|> push_navigate(to: ~p"/activities")}
|
|> push_navigate(to: ~p"/activities")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -96,7 +96,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
socket
|
socket
|
||||||
|> assign(:report_form, report_form())
|
|> assign(:report_form, report_form())
|
||||||
|> assign(:report_message_id, nil)
|
|> assign(:report_message_id, nil)
|
||||||
|> put_flash(:info, "Report sent to moderators.")}
|
|> put_flash(:info, gettext("Report sent to moderators."))}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:noreply, put_flash(socket, :error, error_message(reason))}
|
{:noreply, put_flash(socket, :error, error_message(reason))}
|
||||||
|
|
@ -112,7 +112,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
if message do
|
if message do
|
||||||
{:noreply, assign(socket, :report_message_id, message.id)}
|
{:noreply, assign(socket, :report_message_id, message.id)}
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -125,7 +126,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
{:ok, _block} ->
|
{:ok, _block} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
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")}
|
|> push_navigate(to: ~p"/activities")}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
|
@ -146,7 +150,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, "Activity is unavailable.")
|
|> put_flash(:error, gettext("Activity is unavailable."))
|
||||||
|> push_navigate(to: ~p"/activities")
|
|> push_navigate(to: ~p"/activities")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -180,6 +184,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
|> assign(:organizer?, organizer?)
|
|> assign(:organizer?, organizer?)
|
||||||
|> assign(:approved?, approved?)
|
|> assign(:approved?, approved?)
|
||||||
|> assign(:coordinates, coordinates)
|
|> assign(:coordinates, coordinates)
|
||||||
|
|> assign(
|
||||||
|
:structured_details,
|
||||||
|
structured_details(activity, Gettext.get_locale(WhoNeedHelpWeb.Gettext))
|
||||||
|
)
|
||||||
|> assign(:markers, Jason.encode!(markers))
|
|> assign(:markers, Jason.encode!(markers))
|
||||||
|> assign(:message_form, to_form(%{"body" => ""}, as: :activity_message))
|
|> assign(:message_form, to_form(%{"body" => ""}, as: :activity_message))
|
||||||
|> assign(:report_message_id, nil)
|
|> 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 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
|
defp report_form do
|
||||||
to_form(%{"reason" => "harassment", "details" => ""}, as: :report)
|
to_form(%{"reason" => "harassment", "details" => ""}, as: :report)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp error_message(:already_joined), do: "You already have a join request for this activity."
|
defp error_message(:already_joined),
|
||||||
defp error_message(:capacity_reached), do: "The approved group has reached its capacity."
|
do: gettext("You already have a join request for this activity.")
|
||||||
defp error_message(:join_closed), do: "Join requests are closed."
|
|
||||||
defp error_message(:blocked), do: "This action is unavailable."
|
defp error_message(:capacity_reached),
|
||||||
defp error_message(:cannot_report_self), do: "You cannot report your own content."
|
do: gettext("The approved group has reached its capacity.")
|
||||||
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(:join_closed), do: gettext("Join requests are closed.")
|
||||||
defp error_message(:not_open), do: "This activity is no longer open."
|
defp error_message(:blocked), do: gettext("This action is unavailable.")
|
||||||
defp error_message(:rate_limited), do: "Too many actions in the configured time window."
|
defp error_message(:cannot_report_self), do: gettext("You cannot report your own content.")
|
||||||
defp error_message(%Ecto.Changeset{}), do: "Please check the submitted message."
|
defp error_message(:forbidden), do: gettext("You are not allowed to do that.")
|
||||||
defp error_message(reason), do: "Could not complete the action: #{inspect(reason)}"
|
|
||||||
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
|
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
|
||||||
← Back to activities
|
{gettext("← Back to activities")}
|
||||||
</.link>
|
</.link>
|
||||||
|
|
||||||
<div class="grid gap-7 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
<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">
|
<span class="badge badge-info badge-outline">
|
||||||
{WhoNeedHelp.Catalog.category_path(
|
{WhoNeedHelp.Catalog.category_path(
|
||||||
@activity.category,
|
@activity.category,
|
||||||
@current_scope.user.locale
|
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span class="badge">{@activity.status}</span>
|
<span class="badge">{status_label(@activity.status)}</span>
|
||||||
<span class="badge badge-outline">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mt-4 text-4xl font-black">{@activity.title}</h1>
|
<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">
|
<dl class="mt-6 grid gap-4 rounded-2xl bg-base-200 p-5 sm:grid-cols-2">
|
||||||
<div>
|
<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">
|
<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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dd class="font-semibold">{@activity.location_label}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs text-base-content/50">Organizer</dt>
|
<dt class="text-xs text-base-content/50">{gettext("Organizer")}</dt>
|
||||||
<dd class="font-semibold">{@activity.creator.display_name || "Community member"}</dd>
|
<dd class="font-semibold">
|
||||||
|
{@activity.creator.display_name || gettext("Community member")}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:if={@activity.creator.social_identities != []}
|
:if={@activity.creator.social_identities != []}
|
||||||
class="sm:col-span-2"
|
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">
|
<dd class="mt-2 flex flex-wrap gap-2">
|
||||||
<a
|
<a
|
||||||
:for={identity <- @activity.creator.social_identities}
|
:for={identity <- @activity.creator.social_identities}
|
||||||
|
|
@ -283,35 +355,37 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{identity.provider} · {if identity.verified_at,
|
{identity.provider} · {if identity.verified_at,
|
||||||
do: "verified",
|
do: gettext("verified"),
|
||||||
else: "unverified"}
|
else: gettext("unverified")}
|
||||||
</a>
|
</a>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div :if={map_size(@activity.structured_data) > 0} class="mt-5">
|
<div :if={@structured_details != []} class="mt-5">
|
||||||
<h2 class="font-bold">Plan details</h2>
|
<h2 class="font-bold">{gettext("Plan details")}</h2>
|
||||||
<dl class="mt-3 grid gap-2 sm:grid-cols-2">
|
<dl class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||||
<div
|
<div
|
||||||
:for={{key, value} <- @activity.structured_data}
|
:for={{label, value} <- @structured_details}
|
||||||
class="rounded-xl border border-base-300 p-3"
|
class="rounded-xl border border-base-300 p-3"
|
||||||
>
|
>
|
||||||
<dt class="text-xs text-base-content/50">{String.replace(key, "_", " ")}</dt>
|
<dt class="text-xs text-base-content/50">{label}</dt>
|
||||||
<dd class="font-medium">{to_string(value)}</dd>
|
<dd class="font-medium">{value}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section :if={@approved?} class="rounded-3xl border border-info/30 bg-info/5 p-6">
|
<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">
|
<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>
|
</p>
|
||||||
<div id="activity-messages" class="mt-5 max-h-96 space-y-3 overflow-y-auto">
|
<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">
|
<p :if={@activity.messages == []} class="text-sm text-base-content/50">
|
||||||
No messages yet.
|
{gettext("No messages yet.")}
|
||||||
</p>
|
</p>
|
||||||
<article
|
<article
|
||||||
:for={message <- ordered_messages(@activity)}
|
: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="flex items-center justify-between gap-2">
|
||||||
<div class="text-xs font-semibold text-info">
|
<div class="text-xs font-semibold text-info">
|
||||||
{message.sender.display_name || "Participant"}
|
{message.sender.display_name || gettext("Participant")}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
:if={message.sender_id != @current_scope.user.id}
|
:if={message.sender_id != @current_scope.user.id}
|
||||||
|
|
@ -328,7 +402,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
phx-value-id={message.id}
|
phx-value-id={message.id}
|
||||||
class="btn btn-ghost btn-xs"
|
class="btn btn-ghost btn-xs"
|
||||||
>
|
>
|
||||||
Report
|
{gettext("Report")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 whitespace-pre-wrap text-sm">{message.body}</p>
|
<p class="mt-1 whitespace-pre-wrap text-sm">{message.body}</p>
|
||||||
|
|
@ -343,10 +417,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
>
|
>
|
||||||
<.input
|
<.input
|
||||||
field={@message_form[:body]}
|
field={@message_form[:body]}
|
||||||
placeholder="Message the approved group"
|
placeholder={gettext("Message the approved group")}
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
<.button class="btn btn-info self-end">Send</.button>
|
<.button class="btn btn-info self-end">{gettext("Send")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -360,12 +434,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
class="aid-map"
|
class="aid-map"
|
||||||
/>
|
/>
|
||||||
<div class="rounded-2xl bg-base-200 p-5 text-sm">
|
<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">
|
<p class="mt-2 text-base-content/65">
|
||||||
<%= if @approved? do %>
|
<%= 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -376,12 +452,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
phx-click="join"
|
phx-click="join"
|
||||||
class="btn btn-info w-full"
|
class="btn btn-info w-full"
|
||||||
>
|
>
|
||||||
Request to join
|
{gettext("Request to join")}
|
||||||
</button>
|
</button>
|
||||||
<div :if={@viewer_participant && @viewer_participant.status == :requested}>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -389,18 +465,20 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
phx-click="leave"
|
phx-click="leave"
|
||||||
class="btn btn-outline btn-error w-full"
|
class="btn btn-outline btn-error w-full"
|
||||||
>
|
>
|
||||||
Leave activity
|
{gettext("Leave activity")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section :if={@organizer?} class="rounded-2xl border border-info/30 p-5">
|
<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">
|
<div class="mt-4 space-y-3">
|
||||||
<article
|
<article
|
||||||
:for={participant <- pending_participants(@activity)}
|
:for={participant <- pending_participants(@activity)}
|
||||||
class="rounded-xl bg-base-200 p-3"
|
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
|
<div
|
||||||
:if={participant.user.social_identities != []}
|
:if={participant.user.social_identities != []}
|
||||||
class="mt-2 flex flex-wrap gap-1"
|
class="mt-2 flex flex-wrap gap-1"
|
||||||
|
|
@ -417,8 +495,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{identity.provider} · {if identity.verified_at,
|
{identity.provider} · {if identity.verified_at,
|
||||||
do: "verified",
|
do: gettext("verified"),
|
||||||
else: "unverified"}
|
else: gettext("unverified")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex gap-2">
|
<div class="mt-2 flex gap-2">
|
||||||
|
|
@ -427,38 +505,38 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
phx-value-id={participant.id}
|
phx-value-id={participant.id}
|
||||||
class="btn btn-info btn-xs"
|
class="btn btn-info btn-xs"
|
||||||
>
|
>
|
||||||
Approve
|
{gettext("Approve")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
phx-click="decline"
|
phx-click="decline"
|
||||||
phx-value-id={participant.id}
|
phx-value-id={participant.id}
|
||||||
class="btn btn-ghost btn-xs"
|
class="btn btn-ghost btn-xs"
|
||||||
>
|
>
|
||||||
Decline
|
{gettext("Decline")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<p :if={pending_participants(@activity) == []} class="text-sm text-base-content/50">
|
<p :if={pending_participants(@activity) == []} class="text-sm text-base-content/50">
|
||||||
No pending join requests.
|
{gettext("No pending join requests.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div :if={@activity.status == :open} class="mt-5 grid gap-2">
|
<div :if={@activity.status == :open} class="mt-5 grid gap-2">
|
||||||
<button phx-click="complete" class="btn btn-success btn-sm">
|
<button phx-click="complete" class="btn btn-success btn-sm">
|
||||||
Mark completed
|
{gettext("Mark completed")}
|
||||||
</button>
|
</button>
|
||||||
<button phx-click="cancel" class="btn btn-outline btn-error btn-sm">
|
<button phx-click="cancel" class="btn btn-outline btn-error btn-sm">
|
||||||
Cancel activity
|
{gettext("Cancel activity")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-2xl bg-base-200 p-5">
|
<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">
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
<li :for={participant <- approved_participants(@activity)}>
|
<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">
|
<span :if={participant.role == :organizer} class="badge badge-xs ml-1">
|
||||||
organizer
|
{gettext("organizer")}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
:if={participant.user.social_identities != []}
|
:if={participant.user.social_identities != []}
|
||||||
|
|
@ -476,8 +554,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{identity.provider} · {if identity.verified_at,
|
{identity.provider} · {if identity.verified_at,
|
||||||
do: "verified",
|
do: gettext("verified"),
|
||||||
else: "unverified"}
|
else: gettext("unverified")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -488,43 +566,49 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
:if={not @organizer? or @report_message_id}
|
:if={not @organizer? or @report_message_id}
|
||||||
class="rounded-2xl border border-error/30 p-5"
|
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
|
<button
|
||||||
:if={not @organizer?}
|
:if={not @organizer?}
|
||||||
phx-click="block-organizer"
|
phx-click="block-organizer"
|
||||||
class="btn btn-outline btn-error btn-sm mt-3 w-full"
|
class="btn btn-outline btn-error btn-sm mt-3 w-full"
|
||||||
>
|
>
|
||||||
Block organizer
|
{gettext("Block organizer")}
|
||||||
</button>
|
</button>
|
||||||
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2">
|
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2">
|
||||||
<div
|
<div
|
||||||
:if={@report_message_id}
|
:if={@report_message_id}
|
||||||
class="flex items-center justify-between rounded-xl bg-warning/10 p-3 text-xs"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="clear-report-message"
|
phx-click="clear-report-message"
|
||||||
class="btn btn-ghost btn-xs"
|
class="btn btn-ghost btn-xs"
|
||||||
>
|
>
|
||||||
Clear
|
{gettext("Clear")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<.input
|
<.input
|
||||||
field={@report_form[:reason]}
|
field={@report_form[:reason]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Reason"
|
label={gettext("Reason")}
|
||||||
options={[
|
options={[
|
||||||
{"Harassment", "harassment"},
|
{gettext("Harassment"), "harassment"},
|
||||||
{"Dangerous plan", "dangerous_request"},
|
{gettext("Dangerous plan"), "dangerous_request"},
|
||||||
{"Fraud", "fraud"},
|
{gettext("Fraud"), "fraud"},
|
||||||
{"Spam", "spam"},
|
{gettext("Spam"), "spam"},
|
||||||
{"Impersonation", "impersonation"},
|
{gettext("Impersonation"), "impersonation"},
|
||||||
{"Other", "other"}
|
{gettext("Other"), "other"}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<.input field={@report_form[:details]} type="textarea" label="What happened?" />
|
<.input
|
||||||
<.button class="btn btn-error btn-outline w-full">Send report</.button>
|
field={@report_form[:details]}
|
||||||
|
type="textarea"
|
||||||
|
label={gettext("What happened?")}
|
||||||
|
/>
|
||||||
|
<.button class="btn btn-error btn-outline w-full">
|
||||||
|
{gettext("Send report")}
|
||||||
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
case Catalog.propose(socket.assigns.current_scope, params) do
|
case Catalog.propose(socket.assigns.current_scope, params) do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
{:noreply,
|
{: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} ->
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||||
|
|
@ -32,16 +34,20 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp action_error(:account_not_eligible),
|
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(:rate_limited),
|
||||||
defp action_error(:proposal_closed), do: "This proposal is already closed."
|
do: gettext("Too many actions in the configured time window.")
|
||||||
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(: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
|
defp load(socket) do
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Category proposals")
|
|> assign(:page_title, gettext("Category proposals"))
|
||||||
|> assign(:proposals, Catalog.list_proposals())
|
|> assign(:proposals, Catalog.list_proposals())
|
||||||
|> assign(:categories, Catalog.list_all_categories())
|
|> assign(:categories, Catalog.list_all_categories())
|
||||||
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{})))
|
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{})))
|
||||||
|
|
@ -53,56 +59,76 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
<div class="grid gap-8 lg:grid-cols-[.8fr_1.2fr]">
|
<div class="grid gap-8 lg:grid-cols-[.8fr_1.2fr]">
|
||||||
<section>
|
<section>
|
||||||
<div class="text-sm font-semibold text-success">COMMUNITY-DRIVEN TAXONOMY</div>
|
<div class="text-sm font-semibold text-success">
|
||||||
<h1 class="mt-2 text-4xl font-black">What help category is missing?</h1>
|
{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">
|
<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>
|
</p>
|
||||||
<.form for={@form} phx-submit="propose" class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6">
|
<.form for={@form} phx-submit="propose" class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6">
|
||||||
<.input
|
<.input
|
||||||
field={@form[:proposed_name]}
|
field={@form[:proposed_name]}
|
||||||
label="Proposed category"
|
label={gettext("Proposed category")}
|
||||||
placeholder="Bicycle puncture"
|
placeholder={gettext("Bicycle puncture")}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:mode]}
|
field={@form[:mode]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Mode"
|
label={gettext("Mode")}
|
||||||
options={[{"Urgent help", "help"}, {"Social activity", "activity"}]}
|
options={[
|
||||||
|
{gettext("Urgent help"), "help"},
|
||||||
|
{gettext("Social activity"), "activity"}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:parent_id]}
|
field={@form[:parent_id]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Parent (optional)"
|
label={gettext("Parent (optional)")}
|
||||||
prompt="Top level"
|
prompt={gettext("Top level")}
|
||||||
options={
|
options={
|
||||||
Enum.map(
|
Enum.map(
|
||||||
@categories,
|
@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?" />
|
<.input
|
||||||
<.button class="btn btn-primary w-full">Publish proposal</.button>
|
field={@form[:reason]}
|
||||||
|
type="textarea"
|
||||||
|
label={gettext("Why is this useful?")}
|
||||||
|
/>
|
||||||
|
<.button class="btn btn-primary w-full">{gettext("Publish proposal")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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 class="mt-5 space-y-3">
|
||||||
<div
|
<div
|
||||||
:if={@proposals == []}
|
:if={@proposals == []}
|
||||||
class="rounded-2xl border border-dashed p-8 text-center text-base-content/55"
|
class="rounded-2xl border border-dashed p-8 text-center text-base-content/55"
|
||||||
>
|
>
|
||||||
No proposals yet.
|
{gettext("No proposals yet.")}
|
||||||
</div>
|
</div>
|
||||||
<article :for={proposal <- @proposals} class="rounded-2xl border border-base-300 p-5">
|
<article :for={proposal <- @proposals} class="rounded-2xl border border-base-300 p-5">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold">{proposal.proposed_name}</h3>
|
<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-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>
|
</div>
|
||||||
<button phx-click="vote" phx-value-id={proposal.id} class="btn btn-outline btn-sm">
|
<button phx-click="vote" phx-value-id={proposal.id} class="btn btn-outline btn-sm">
|
||||||
▲ {length(proposal.votes)}
|
▲ {length(proposal.votes)}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Community helpers")
|
|> assign(:page_title, gettext("Community helpers"))
|
||||||
|> assign(:leaders, Trust.leaderboard())}
|
|> assign(:leaders, Trust.leaderboard())}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -15,33 +15,37 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<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="mt-5 max-w-4xl">
|
||||||
<div class="text-sm font-semibold text-success">COMMUNITY TRUST</div>
|
<div class="text-sm font-semibold text-success">{gettext("COMMUNITY TRUST")}</div>
|
||||||
<h1 class="mt-1 text-4xl font-black tracking-tight">Helpers leaderboard</h1>
|
<h1 class="mt-1 text-4xl font-black tracking-tight">
|
||||||
|
{gettext("Helpers leaderboard")}
|
||||||
|
</h1>
|
||||||
<p class="mt-3 text-base-content/65">
|
<p class="mt-3 text-base-content/65">
|
||||||
Ranking prioritizes unique people helped with optional location-supported evidence,
|
{gettext(
|
||||||
then unique verified handovers. Repeated help between the same pair does not inflate
|
"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."
|
||||||
the primary score.
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-7 overflow-x-auto rounded-3xl border border-base-300">
|
<div class="mt-7 overflow-x-auto rounded-3xl border border-base-300">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Rank</th>
|
<th>{gettext("Rank")}</th>
|
||||||
<th>Helper</th>
|
<th>{gettext("Helper")}</th>
|
||||||
<th>Location-supported people</th>
|
<th>{gettext("Location-supported people")}</th>
|
||||||
<th>Verified people</th>
|
<th>{gettext("Verified people")}</th>
|
||||||
<th>Unique people</th>
|
<th>{gettext("Unique people")}</th>
|
||||||
<th>Total</th>
|
<th>{gettext("Total")}</th>
|
||||||
<th>Rating</th>
|
<th>{gettext("Rating")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr :if={@leaders == []}>
|
<tr :if={@leaders == []}>
|
||||||
<td colspan="7" class="py-10 text-center text-base-content/55">
|
<td colspan="7" class="py-10 text-center text-base-content/55">
|
||||||
No completed help yet.
|
{gettext("No completed help yet.")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr :for={{entry, index} <- Enum.with_index(@leaders, 1)}>
|
<tr :for={{entry, index} <- Enum.with_index(@leaders, 1)}>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -13,7 +14,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Trust.moderate_report(socket.assigns.current_scope, id, params),
|
Trust.moderate_report(socket.assigns.current_scope, id, params),
|
||||||
"Report updated."
|
gettext("Report updated.")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -30,7 +31,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Trust.moderate_signal(socket.assigns.current_scope, id, params),
|
Trust.moderate_signal(socket.assigns.current_scope, id, params),
|
||||||
"Signal updated."
|
gettext("Signal updated.")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -38,7 +39,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Trust.moderate_user(socket.assigns.current_scope, id, params),
|
Trust.moderate_user(socket.assigns.current_scope, id, params),
|
||||||
"User status updated."
|
gettext("User status updated.")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -46,7 +47,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Trust.moderate_role(socket.assigns.current_scope, id, params),
|
Trust.moderate_role(socket.assigns.current_scope, id, params),
|
||||||
"User role updated."
|
gettext("User role updated.")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -54,7 +55,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Trust.hide_request(socket.assigns.current_scope, id, note),
|
Trust.hide_request(socket.assigns.current_scope, id, note),
|
||||||
"Request hidden."
|
gettext("Request hidden.")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -62,7 +63,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Trust.restore_request(socket.assigns.current_scope, id),
|
Trust.restore_request(socket.assigns.current_scope, id),
|
||||||
"Request restored."
|
gettext("Request restored.")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -70,7 +71,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Trust.hide_activity(socket.assigns.current_scope, id, note),
|
Trust.hide_activity(socket.assigns.current_scope, id, note),
|
||||||
"Activity hidden."
|
gettext("Activity hidden.")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -78,7 +79,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Trust.restore_activity(socket.assigns.current_scope, id),
|
Trust.restore_activity(socket.assigns.current_scope, id),
|
||||||
"Activity restored."
|
gettext("Activity restored.")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -91,12 +92,20 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
}
|
}
|
||||||
|> Map.reject(fn {_locale, name} -> name in [nil, ""] end)
|
|> 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"] || "{}"),
|
with {:ok, structured_fields} <- Jason.decode(params["structured_fields_json"] || "{}"),
|
||||||
true <- is_map(structured_fields) do
|
true <- is_map(structured_fields) do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
"slug" => params["slug"],
|
"slug" => params["slug"],
|
||||||
"names" => names,
|
"names" => names,
|
||||||
"description" => params["description"],
|
"descriptions" => descriptions,
|
||||||
"structured_fields" => structured_fields,
|
"structured_fields" => structured_fields,
|
||||||
"moderation_note" => params["moderation_note"]
|
"moderation_note" => params["moderation_note"]
|
||||||
}
|
}
|
||||||
|
|
@ -104,11 +113,16 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Catalog.approve_proposal(socket.assigns.current_scope, id, attrs),
|
Catalog.approve_proposal(socket.assigns.current_scope, id, attrs),
|
||||||
"Category created and proposal approved."
|
gettext("Category created and proposal approved.")
|
||||||
)
|
)
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -116,7 +130,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Catalog.reject_proposal(socket.assigns.current_scope, id, note),
|
Catalog.reject_proposal(socket.assigns.current_scope, id, note),
|
||||||
"Proposal rejected."
|
gettext("Proposal rejected.")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -128,7 +142,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
Catalog.merge_proposal(socket.assigns.current_scope, id, category_id, note),
|
Catalog.merge_proposal(socket.assigns.current_scope, id, category_id, note),
|
||||||
"Proposal merged."
|
gettext("Proposal merged.")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -149,29 +163,74 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
|> assign(:categories, Catalog.list_all_categories())
|
|> assign(:categories, Catalog.list_all_categories())
|
||||||
end
|
end
|
||||||
|
|
||||||
defp error_message(:forbidden), do: "Moderator access is required."
|
defp error_message(:forbidden), do: gettext("Moderator access is required.")
|
||||||
defp error_message(:proposal_closed), do: "This proposal has already been reviewed."
|
defp error_message(:proposal_closed), do: gettext("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(:cannot_restrict_self),
|
||||||
defp error_message(%Ecto.Changeset{}), do: "Please check the submitted fields."
|
do: gettext("You cannot restrict your own moderator account.")
|
||||||
defp error_message(reason), do: "Could not complete moderation: #{inspect(reason)}"
|
|
||||||
|
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
|
defp scoped_form(data, as, scope) do
|
||||||
to_form(data, as: as, id: "#{as}-#{scope}")
|
to_form(data, as: as, id: "#{as}-#{scope}")
|
||||||
end
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
<div class="text-sm font-semibold text-warning">RESTRICTED WORKSPACE</div>
|
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div>
|
||||||
<h1 class="mt-1 text-4xl font-black">Moderation</h1>
|
<h1 class="mt-1 text-4xl font-black">{gettext("Moderation")}</h1>
|
||||||
<p class="mt-2 text-base-content/60">
|
<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>
|
</p>
|
||||||
|
|
||||||
<section class="mt-8">
|
<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">
|
<div class="mt-4 space-y-4">
|
||||||
<article :for={report <- @reports} class="rounded-2xl border border-base-300 p-5">
|
<article :for={report <- @reports} class="rounded-2xl border border-base-300 p-5">
|
||||||
<% hide_form = scoped_form(%{"note" => ""}, :moderation, "report-hide-#{report.id}") %>
|
<% hide_form = scoped_form(%{"note" => ""}, :moderation, "report-hide-#{report.id}") %>
|
||||||
|
|
@ -182,13 +241,17 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
"report-review-#{report.id}"
|
"report-review-#{report.id}"
|
||||||
) %>
|
) %>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="badge badge-error">{report.reason}</span>
|
<span class="badge badge-error">{reason_label(report.reason)}</span>
|
||||||
<span class="badge">{report.status}</span>
|
<span class="badge">{status_label(report.status)}</span>
|
||||||
<span :if={report.activity_id} class="badge badge-info badge-outline">activity</span>
|
<span :if={report.activity_id} class="badge badge-info badge-outline">
|
||||||
<span :if={report.activity_message_id} class="badge badge-info badge-outline">
|
{gettext("activity")}
|
||||||
activity message
|
</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>
|
||||||
<span class="text-xs text-base-content/50">by {report.reporter.display_name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 whitespace-pre-wrap text-sm">{report.details}</p>
|
<p class="mt-3 whitespace-pre-wrap text-sm">{report.details}</p>
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
|
@ -197,7 +260,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
phx-value-id={report.id}
|
phx-value-id={report.id}
|
||||||
class="btn btn-sm btn-outline"
|
class="btn btn-sm btn-outline"
|
||||||
>
|
>
|
||||||
View scoped evidence
|
{gettext("View scoped evidence")}
|
||||||
</button>
|
</button>
|
||||||
<.form
|
<.form
|
||||||
:if={report.request_id}
|
:if={report.request_id}
|
||||||
|
|
@ -206,8 +269,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
phx-value-id={report.request_id}
|
phx-value-id={report.request_id}
|
||||||
class="flex gap-2"
|
class="flex gap-2"
|
||||||
>
|
>
|
||||||
<.input field={hide_form[:note]} placeholder="Hide reason" />
|
<.input field={hide_form[:note]} placeholder={gettext("Hide reason")} />
|
||||||
<.button class="btn btn-sm btn-error self-end">Hide request</.button>
|
<.button class="btn btn-sm btn-error self-end">
|
||||||
|
{gettext("Hide request")}
|
||||||
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
<.form
|
<.form
|
||||||
:if={report.activity_id}
|
:if={report.activity_id}
|
||||||
|
|
@ -216,8 +281,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
phx-value-id={report.activity_id}
|
phx-value-id={report.activity_id}
|
||||||
class="flex gap-2"
|
class="flex gap-2"
|
||||||
>
|
>
|
||||||
<.input field={hide_form[:note]} placeholder="Hide reason" />
|
<.input field={hide_form[:note]} placeholder={gettext("Hide reason")} />
|
||||||
<.button class="btn btn-sm btn-error self-end">Hide activity</.button>
|
<.button class="btn btn-sm btn-error self-end">
|
||||||
|
{gettext("Hide activity")}
|
||||||
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</div>
|
</div>
|
||||||
<.form
|
<.form
|
||||||
|
|
@ -229,23 +296,30 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
<.input
|
<.input
|
||||||
field={report_form[:status]}
|
field={report_form[:status]}
|
||||||
type="select"
|
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" />
|
<.input
|
||||||
<.button class="btn btn-primary self-end">Save</.button>
|
field={report_form[:resolution_note]}
|
||||||
|
placeholder={gettext("Resolution note")}
|
||||||
|
/>
|
||||||
|
<.button class="btn btn-primary self-end">{gettext("Save")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</article>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section :if={@evidence} class="mt-8 rounded-3xl bg-neutral p-6 text-neutral-content">
|
<section :if={@evidence} class="mt-8 rounded-3xl bg-neutral p-6 text-neutral-content">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-xl font-bold">Reported evidence</h2>
|
<h2 class="text-xl font-bold">{gettext("Reported evidence")}</h2>
|
||||||
<button phx-click="close-evidence" class="btn btn-sm">Close</button>
|
<button phx-click="close-evidence" class="btn btn-sm">{gettext("Close")}</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-sm opacity-70">
|
<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>
|
</p>
|
||||||
<div class="mt-4 space-y-2">
|
<div class="mt-4 space-y-2">
|
||||||
<div :for={message <- @evidence.messages} class="rounded-xl bg-white/10 p-3 text-sm">
|
<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 == []}
|
:if={@evidence.messages == [] and @evidence.activity_messages == []}
|
||||||
class="text-sm opacity-60"
|
class="text-sm opacity-60"
|
||||||
>
|
>
|
||||||
This report has no linked conversation.
|
{gettext("This report has no linked conversation.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-10">
|
<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">
|
<div class="mt-4 space-y-3">
|
||||||
<article :for={signal <- @signals} class="rounded-2xl bg-base-200 p-5">
|
<article :for={signal <- @signals} class="rounded-2xl bg-base-200 p-5">
|
||||||
<% signal_form =
|
<% signal_form =
|
||||||
|
|
@ -276,9 +350,13 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
:moderation,
|
:moderation,
|
||||||
"signal-#{signal.id}"
|
"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">
|
<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>
|
</p>
|
||||||
<.form
|
<.form
|
||||||
for={signal_form}
|
for={signal_form}
|
||||||
|
|
@ -289,18 +367,23 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
<.input
|
<.input
|
||||||
field={signal_form[:status]}
|
field={signal_form[:status]}
|
||||||
type="select"
|
type="select"
|
||||||
options={["reviewed", "dismissed"]}
|
options={[
|
||||||
|
{gettext("Reviewed"), "reviewed"},
|
||||||
|
{gettext("Dismissed"), "dismissed"}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<.input field={signal_form[:review_note]} placeholder="Review note" />
|
<.input field={signal_form[:review_note]} placeholder={gettext("Review 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>
|
||||||
</article>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-10">
|
<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">
|
<div class="mt-4 space-y-4">
|
||||||
<article :for={proposal <- @proposals} class="rounded-2xl border border-base-300 p-5">
|
<article :for={proposal <- @proposals} class="rounded-2xl border border-base-300 p-5">
|
||||||
<% category_form =
|
<% category_form =
|
||||||
|
|
@ -314,10 +397,13 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
<% merge_form =
|
<% merge_form =
|
||||||
scoped_form(%{}, :moderation, "merge-#{proposal.id}") %>
|
scoped_form(%{}, :moderation, "merge-#{proposal.id}") %>
|
||||||
<h3 class="font-bold">
|
<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>
|
</h3>
|
||||||
<p class="mt-1 text-sm">{proposal.reason}</p>
|
<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">
|
<div :if={proposal.status == :open} class="mt-4 grid gap-4 xl:grid-cols-2">
|
||||||
<.form
|
<.form
|
||||||
for={category_form}
|
for={category_form}
|
||||||
|
|
@ -325,21 +411,38 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
phx-value-id={proposal.id}
|
phx-value-id={proposal.id}
|
||||||
class="space-y-2 rounded-xl bg-base-200 p-4"
|
class="space-y-2 rounded-xl bg-base-200 p-4"
|
||||||
>
|
>
|
||||||
<.input field={category_form[:slug]} label="Slug" />
|
<.input field={category_form[:slug]} label={gettext("Slug")} />
|
||||||
<.input field={category_form[:name_en]} label="English name" />
|
<.input field={category_form[:name_en]} label={gettext("English name")} />
|
||||||
<.input
|
<.input
|
||||||
field={category_form[:name_uk]}
|
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
|
<.input
|
||||||
field={category_form[:structured_fields_json]}
|
field={category_form[:structured_fields_json]}
|
||||||
type="textarea"
|
type="textarea"
|
||||||
label="Structured fields JSON"
|
label={gettext("Structured fields JSON")}
|
||||||
/>
|
/>
|
||||||
<.input field={category_form[:moderation_note]} label="Decision note" />
|
<.input
|
||||||
<.button class="btn btn-success w-full">Create category</.button>
|
field={category_form[:moderation_note]}
|
||||||
|
label={gettext("Decision note")}
|
||||||
|
/>
|
||||||
|
<.button class="btn btn-success w-full">{gettext("Create category")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<.form
|
<.form
|
||||||
|
|
@ -348,8 +451,8 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
phx-value-id={proposal.id}
|
phx-value-id={proposal.id}
|
||||||
class="flex gap-2"
|
class="flex gap-2"
|
||||||
>
|
>
|
||||||
<.input field={reject_form[:note]} placeholder="Rejection note" />
|
<.input field={reject_form[:note]} placeholder={gettext("Rejection note")} />
|
||||||
<.button class="btn btn-error self-end">Reject</.button>
|
<.button class="btn btn-error self-end">{gettext("Reject")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
<.form
|
<.form
|
||||||
for={merge_form}
|
for={merge_form}
|
||||||
|
|
@ -360,13 +463,13 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
<.input
|
<.input
|
||||||
field={merge_form[:category_id]}
|
field={merge_form[:category_id]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Merge into"
|
label={gettext("Merge into")}
|
||||||
options={
|
options={
|
||||||
Enum.map(@categories, &{WhoNeedHelp.Catalog.Category.name(&1, "en"), &1.id})
|
Enum.map(@categories, &{WhoNeedHelp.Catalog.Category.name(&1, "en"), &1.id})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<.input field={merge_form[:note]} placeholder="Merge note" />
|
<.input field={merge_form[:note]} placeholder={gettext("Merge note")} />
|
||||||
<.button class="btn btn-outline w-full">Merge</.button>
|
<.button class="btn btn-outline w-full">{gettext("Merge")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -375,12 +478,15 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-10">
|
<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">
|
<div class="mt-4 overflow-x-auto rounded-2xl border border-base-300">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -404,8 +510,8 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
{user.display_name}
|
{user.display_name}
|
||||||
<div class="text-xs opacity-50">{user.email}</div>
|
<div class="text-xs opacity-50">{user.email}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{user.role}</td>
|
<td>{role_label(user.role)}</td>
|
||||||
<td>{user.moderation_status}</td>
|
<td>{status_label(user.moderation_status)}</td>
|
||||||
<td>
|
<td>
|
||||||
<.form
|
<.form
|
||||||
for={user_form}
|
for={user_form}
|
||||||
|
|
@ -416,13 +522,17 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
<.input
|
<.input
|
||||||
field={user_form[:moderation_status]}
|
field={user_form[:moderation_status]}
|
||||||
type="select"
|
type="select"
|
||||||
options={["active", "restricted", "suspended"]}
|
options={[
|
||||||
|
{gettext("Active"), "active"},
|
||||||
|
{gettext("Restricted"), "restricted"},
|
||||||
|
{gettext("Suspended"), "suspended"}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={user_form[:moderation_note]}
|
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>
|
||||||
<.form
|
<.form
|
||||||
:if={@current_scope.user.role == :admin}
|
:if={@current_scope.user.role == :admin}
|
||||||
|
|
@ -434,9 +544,15 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
<.input
|
<.input
|
||||||
field={role_form[:role]}
|
field={role_form[:role]}
|
||||||
type="select"
|
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>
|
</.form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Profile")
|
|> assign(:page_title, gettext("Profile"))
|
||||||
|> assign(:reputation, Trust.reputation(user.id))
|
|> assign(:reputation, Trust.reputation(user.id))
|
||||||
|> assign(:reviews, Trust.visible_reviews(user.id))
|
|> assign(:reviews, Trust.visible_reviews(user.id))
|
||||||
|> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope))
|
|> 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
|
def handle_event("save", %{"user" => params}, socket) do
|
||||||
case Accounts.update_user_profile(socket.assigns.current_scope.user, params) do
|
case Accounts.update_user_profile(socket.assigns.current_scope.user, params) do
|
||||||
{:ok, user} ->
|
{:ok, user} ->
|
||||||
|
Gettext.put_locale(WhoNeedHelpWeb.Gettext, user.locale)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:current_scope, WhoNeedHelp.Accounts.Scope.for_user(user))
|
|> assign(:current_scope, WhoNeedHelp.Accounts.Scope.for_user(user))
|
||||||
|> assign(:form, to_form(Accounts.change_user_profile(user)))
|
|> assign(:form, to_form(Accounts.change_user_profile(user)))
|
||||||
|> put_flash(:info, "Profile updated.")}
|
|> put_flash(:info, gettext("Profile updated."))}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||||
|
|
@ -43,7 +45,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign_social_identities(user)
|
|> assign_social_identities(user)
|
||||||
|> put_flash(:info, "Social link added as unverified.")}
|
|> put_flash(:info, gettext("Social link added as unverified."))}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
{:noreply, assign(socket, :social_form, to_form(changeset, as: :social_identity))}
|
{: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)}
|
{:noreply, assign_social_identities(socket, user)}
|
||||||
|
|
||||||
{:error, :not_found} ->
|
{: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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -68,10 +70,15 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope))
|
|> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope))
|
||||||
|> put_flash(:info, "User unblocked.")}
|
|> put_flash(:info, gettext("User unblocked."))}
|
||||||
|
|
||||||
{:error, reason} ->
|
{: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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -92,72 +99,78 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
<div class="mx-auto max-w-4xl">
|
<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]">
|
<div class="mt-6 grid gap-6 md:grid-cols-[1fr_.75fr]">
|
||||||
<.form
|
<.form
|
||||||
for={@form}
|
for={@form}
|
||||||
phx-submit="save"
|
phx-submit="save"
|
||||||
class="space-y-4 rounded-3xl border border-base-300 p-6"
|
class="space-y-4 rounded-3xl border border-base-300 p-6"
|
||||||
>
|
>
|
||||||
<.input field={@form[:display_name]} label="Display name" />
|
<.input field={@form[:display_name]} label={gettext("Display name")} />
|
||||||
<.input field={@form[:bio]} type="textarea" label="Short bio" />
|
<.input field={@form[:bio]} type="textarea" label={gettext("Short bio")} />
|
||||||
<.input
|
<.input
|
||||||
field={@form[:locale]}
|
field={@form[:locale]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Interface language"
|
label={gettext("Interface language")}
|
||||||
options={[{"English", "en"}, {"Українська", "uk"}, {"Русский", "ru"}]}
|
options={[
|
||||||
|
{gettext("English"), "en"},
|
||||||
|
{"Українська", "uk"},
|
||||||
|
{"Русский", "ru"}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:location_visibility]}
|
field={@form[:location_visibility]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Default location visibility"
|
label={gettext("Default location visibility")}
|
||||||
options={[
|
options={[
|
||||||
{"Hidden", "hidden"},
|
{gettext("Hidden"), "hidden"},
|
||||||
{"Approximate publicly", "approximate_public"},
|
{gettext("Approximate publicly"), "approximate_public"},
|
||||||
{"Exact for active match", "exact_for_active_match"},
|
{gettext("Exact for active match"), "exact_for_active_match"},
|
||||||
{"Exact publicly", "exact_public"}
|
{gettext("Exact publicly"), "exact_public"}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:tip_url]}
|
field={@form[:tip_url]}
|
||||||
label="Optional external thank-you link"
|
label={gettext("Optional external thank-you link")}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-base-content/55">
|
<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>
|
</p>
|
||||||
<.button class="btn btn-primary w-full">Save profile</.button>
|
<.button class="btn btn-primary w-full">{gettext("Save profile")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
<aside class="space-y-5">
|
<aside class="space-y-5">
|
||||||
<div class="rounded-3xl bg-success p-6 text-success-content">
|
<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 class="mt-5 grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-3xl font-black">{@reputation.completed}</div><div class="text-xs">
|
<div class="text-3xl font-black">{@reputation.completed}</div><div class="text-xs">
|
||||||
completed
|
{gettext("completed")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-3xl font-black">{@reputation.unique_people}</div><div class="text-xs">
|
<div class="text-3xl font-black">{@reputation.unique_people}</div><div class="text-xs">
|
||||||
unique people
|
{gettext("unique people")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-3xl font-black">{@reputation.verified_handovers}</div><div class="text-xs">
|
<div class="text-3xl font-black">{@reputation.verified_handovers}</div><div class="text-xs">
|
||||||
verified handovers
|
{gettext("verified handovers")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-3xl font-black">{@reputation.rating || "—"}</div><div class="text-xs">
|
<div class="text-3xl font-black">{@reputation.rating || "—"}</div><div class="text-xs">
|
||||||
rating
|
{gettext("rating")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-3xl bg-base-200 p-5">
|
<div class="rounded-3xl bg-base-200 p-5">
|
||||||
<h3 class="font-bold">Visible reviews</h3>
|
<h3 class="font-bold">{gettext("Visible reviews")}</h3>
|
||||||
<p :if={@reviews == []} class="mt-3 text-sm text-base-content/55">None revealed yet.</p>
|
<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 :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="font-semibold">{"★" |> String.duplicate(review.rating)}</div>
|
||||||
<div class="mt-1">{review.comment}</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">
|
<section class="mt-6 rounded-3xl border border-base-300 p-6">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<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">
|
<p class="mt-1 text-sm text-base-content/55">
|
||||||
Manually added links are public and marked unverified. A verified badge is only
|
{gettext(
|
||||||
granted after completing a provider authorization flow.
|
"Manually added links are public and marked unverified. A verified badge is only granted after completing a provider authorization flow."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<.link
|
<.link
|
||||||
|
|
@ -180,7 +194,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
href={~p"/auth/social/github"}
|
href={~p"/auth/social/github"}
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
>
|
>
|
||||||
Verify GitHub
|
{gettext("Verify GitHub")}
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -188,14 +202,15 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
:if={!@github_oauth_enabled}
|
:if={!@github_oauth_enabled}
|
||||||
class="mt-4 rounded-2xl bg-base-200 p-4 text-sm text-base-content/60"
|
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
|
{gettext(
|
||||||
credentials. No manually entered link is treated as verified.
|
"GitHub verification is unavailable until the operator configures its OAuth credentials. No manually entered link is treated as verified."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-5 grid gap-4 md:grid-cols-[1fr_.9fr]">
|
<div class="mt-5 grid gap-4 md:grid-cols-[1fr_.9fr]">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<p :if={@social_identities == []} class="text-sm text-base-content/55">
|
<p :if={@social_identities == []} class="text-sm text-base-content/55">
|
||||||
No social links added.
|
{gettext("No social links added.")}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
:for={identity <- @social_identities}
|
:for={identity <- @social_identities}
|
||||||
|
|
@ -209,7 +224,9 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
identity.verified_at && "badge-success",
|
identity.verified_at && "badge-success",
|
||||||
is_nil(identity.verified_at) && "badge-warning"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
|
|
@ -227,7 +244,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
phx-value-id={identity.id}
|
phx-value-id={identity.id}
|
||||||
class="btn btn-ghost btn-sm text-error"
|
class="btn btn-ghost btn-sm text-error"
|
||||||
>
|
>
|
||||||
Remove
|
{gettext("Remove")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -236,32 +253,36 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
<.input
|
<.input
|
||||||
field={@social_form[:provider]}
|
field={@social_form[:provider]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Network"
|
label={gettext("Network")}
|
||||||
options={[
|
options={[
|
||||||
{"GitHub", "github"},
|
{"GitHub", "github"},
|
||||||
{"Instagram", "instagram"},
|
{"Instagram", "instagram"},
|
||||||
{"Facebook", "facebook"},
|
{"Facebook", "facebook"},
|
||||||
{"Telegram", "telegram"},
|
{"Telegram", "telegram"},
|
||||||
{"Google", "google"},
|
{"Google", "google"},
|
||||||
{"Other", "other"}
|
{gettext("Other"), "other"}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@social_form[:profile_url]}
|
field={@social_form[:profile_url]}
|
||||||
type="url"
|
type="url"
|
||||||
label="Public profile URL"
|
label={gettext("Public profile URL")}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
/>
|
/>
|
||||||
<.input field={@social_form[:handle]} label="Handle (optional)" placeholder="@name" />
|
<.input
|
||||||
<.button class="btn btn-outline w-full">Add unverified link</.button>
|
field={@social_form[:handle]}
|
||||||
|
label={gettext("Handle (optional)")}
|
||||||
|
placeholder="@name"
|
||||||
|
/>
|
||||||
|
<.button class="btn btn-outline w-full">{gettext("Add unverified link")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-6 rounded-3xl border border-base-300 p-6">
|
<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">
|
<p :if={@blocks == []} class="mt-3 text-sm text-base-content/55">
|
||||||
You have not blocked anyone.
|
{gettext("You have not blocked anyone.")}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
:for={block <- @blocks}
|
:for={block <- @blocks}
|
||||||
|
|
@ -273,7 +294,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
phx-value-id={block.blocked_id}
|
phx-value-id={block.blocked_id}
|
||||||
class="btn btn-sm btn-outline"
|
class="btn btn-sm btn-outline"
|
||||||
>
|
>
|
||||||
Unblock
|
{gettext("Unblock")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
user = socket.assigns.current_scope.user
|
user = socket.assigns.current_scope.user
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Nearby help")
|
|> assign(:page_title, gettext("Nearby help"))
|
||||||
|> assign(:requests, requests)
|
|> assign(:requests, requests)
|
||||||
|> assign(:my_requests, Help.list_my_requests(socket.assigns.current_scope))
|
|> assign(:my_requests, Help.list_my_requests(socket.assigns.current_scope))
|
||||||
|> assign(:reputation, Trust.reputation(user.id))
|
|> assign(:reputation, Trust.reputation(user.id))
|
||||||
|
|
@ -51,24 +51,52 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
end
|
end
|
||||||
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<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 class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-semibold text-success">NEARBY MUTUAL AID</div>
|
<div class="text-sm font-semibold text-success">{gettext("NEARBY MUTUAL AID")}</div>
|
||||||
<h1 class="mt-1 text-4xl font-black tracking-tight">Who needs help?</h1>
|
<h1 class="mt-1 text-4xl font-black tracking-tight">{gettext("Who needs help?")}</h1>
|
||||||
<p class="mt-2 text-base-content/65">
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="alert alert-warning mt-5 text-sm">
|
<div class="alert alert-warning mt-5 text-sm">
|
||||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
<.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>
|
</div>
|
||||||
|
|
||||||
<.form
|
<.form
|
||||||
|
|
@ -79,24 +107,27 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
<.input
|
<.input
|
||||||
field={@filter_form[:category_id]}
|
field={@filter_form[:category_id]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Category"
|
label={gettext("Category")}
|
||||||
options={[
|
options={[
|
||||||
{"All categories", ""}
|
{gettext("All categories"), ""}
|
||||||
| Enum.map(
|
| Enum.map(
|
||||||
@categories,
|
@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
|
<.input
|
||||||
field={@filter_form[:urgency]}
|
field={@filter_form[:urgency]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Urgency"
|
label={gettext("Urgency")}
|
||||||
options={[
|
options={[
|
||||||
{"Any urgency", ""},
|
{gettext("Any urgency"), ""},
|
||||||
{"Now", "now"},
|
{gettext("Now"), "now"},
|
||||||
{"Today", "today"},
|
{gettext("Today"), "today"},
|
||||||
{"Scheduled", "scheduled"}
|
{gettext("Scheduled"), "scheduled"}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
@ -105,9 +136,9 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<%= if @requests == [] do %>
|
<%= if @requests == [] do %>
|
||||||
<div class="rounded-3xl border border-dashed border-base-300 p-12 text-center">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -121,9 +152,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-3 flex flex-wrap gap-2">
|
<div class="mb-3 flex flex-wrap gap-2">
|
||||||
<span class="badge badge-success badge-outline">
|
<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>
|
||||||
<span class="badge badge-error badge-outline">{request.urgency}</span>
|
<span class="badge badge-error badge-outline">{urgency_label(request.urgency)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-xl font-bold">{request.title}</h2>
|
<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>
|
<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>
|
||||||
<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">
|
<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>📍 {request.location_label}</span>
|
||||||
<span>by {request.requester.display_name || "Community member"}</span>
|
<span>
|
||||||
<span>expires {Calendar.strftime(request.expires_at, "%d %b, %H:%M UTC")}</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>
|
</div>
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,21 +189,21 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
class="aid-map"
|
class="aid-map"
|
||||||
/>
|
/>
|
||||||
<div class="rounded-3xl bg-base-200 p-5">
|
<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 class="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-2xl font-black">{@reputation.completed}</div><div class="text-[.65rem]">
|
<div class="text-2xl font-black">{@reputation.completed}</div><div class="text-[.65rem]">
|
||||||
completed
|
{gettext("completed")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-2xl font-black">{@reputation.unique_people}</div><div class="text-[.65rem]">
|
<div class="text-2xl font-black">{@reputation.unique_people}</div><div class="text-[.65rem]">
|
||||||
people
|
{gettext("people")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-2xl font-black">{@reputation.verified_handovers}</div><div class="text-[.65rem]">
|
<div class="text-2xl font-black">{@reputation.verified_handovers}</div><div class="text-[.65rem]">
|
||||||
verified
|
{gettext("verified")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,14 +212,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section :if={@my_requests != []} class="mt-12">
|
<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">
|
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
<.link
|
<.link
|
||||||
:for={request <- @my_requests}
|
:for={request <- @my_requests}
|
||||||
navigate={~p"/requests/#{request.id}"}
|
navigate={~p"/requests/#{request.id}"}
|
||||||
class="rounded-2xl bg-base-200 p-4"
|
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>
|
<div class="mt-2 font-semibold">{request.title}</div>
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Ask for help")
|
|> assign(:page_title, gettext("Ask for help"))
|
||||||
|> assign(:categories, Catalog.list_categories())
|
|> assign(:categories, Catalog.list_categories())
|
||||||
|> assign(:selected_category, nil)
|
|> assign(:selected_category, nil)
|
||||||
|> assign(:structured_fields, [])
|
|> assign(:structured_fields, [])
|
||||||
|
|
@ -44,7 +44,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
{:ok, request} ->
|
{:ok, request} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
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}")}
|
|> push_navigate(to: ~p"/requests/#{request.id}")}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
|
|
@ -101,54 +101,67 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
form.params |> Map.get("structured_data", %{}) |> Map.get(key)
|
form.params |> Map.get("structured_data", %{}) |> Map.get(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp category_description(nil), do: nil
|
defp category_description(nil, _locale), do: nil
|
||||||
defp category_description(category), do: category.description
|
|
||||||
|
defp category_description(category, locale),
|
||||||
|
do: WhoNeedHelp.Catalog.Category.description(category, locale)
|
||||||
|
|
||||||
defp category_title_placeholder(%{slug: "medicine-pickup"}),
|
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"}}),
|
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
|
defp safety_confirmation(%{slug: "medicine-pickup"}) do
|
||||||
"I confirm this is not an emergency, medical advice request, controlled substance, " <>
|
gettext(
|
||||||
"or request to buy medicine on my behalf."
|
"I confirm this is not an emergency, medical advice request, controlled substance, or request to buy medicine on my behalf."
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp safety_confirmation(%{parent: %{slug: "roadside-help"}}) do
|
defp safety_confirmation(%{parent: %{slug: "roadside-help"}}) do
|
||||||
"I confirm there is no immediate danger, people and vehicles are away from active traffic " <>
|
gettext(
|
||||||
"or emergency services have already been contacted, and the requested task is lawful."
|
"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
|
end
|
||||||
|
|
||||||
defp safety_confirmation(_category) do
|
defp safety_confirmation(_category) do
|
||||||
|
gettext(
|
||||||
"I confirm this is not an emergency, there is no immediate danger, and the requested task is lawful."
|
"I confirm this is not an emergency, there is no immediate danger, and the requested task is lawful."
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp error_message(:account_not_eligible),
|
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(:rate_limited),
|
||||||
defp error_message(reason), do: "Could not publish the request: #{inspect(reason)}"
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm mb-4">← Back to requests</.link>
|
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm mb-4">
|
||||||
<h1 class="text-4xl font-black">Ask for urgent help</h1>
|
{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">
|
<p class="mt-2 text-base-content/65">
|
||||||
Medicine pickup remains the first priority. Roadside categories use the same moderated,
|
{gettext(
|
||||||
extensible request flow with details specific to each type of help.
|
"Medicine pickup remains the first priority. Roadside categories use the same moderated, extensible request flow with details specific to each type of help."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="alert alert-warning mt-6 text-sm">
|
<div class="alert alert-warning mt-6 text-sm">
|
||||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
<span>
|
<span>
|
||||||
This is not an emergency service. Contact local emergency services for immediate danger.
|
{gettext(
|
||||||
Never include prescription details, payment card data, access codes, or other sensitive information.
|
"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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -162,37 +175,50 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
<.input
|
<.input
|
||||||
field={@form[:category_id]}
|
field={@form[:category_id]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Category"
|
label={gettext("Category")}
|
||||||
prompt="Choose category"
|
prompt={gettext("Choose category")}
|
||||||
options={
|
options={
|
||||||
Enum.map(
|
Enum.map(
|
||||||
@categories,
|
@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
|
<p
|
||||||
:if={category_description(@selected_category)}
|
:if={
|
||||||
|
category_description(
|
||||||
|
@selected_category,
|
||||||
|
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||||
|
)
|
||||||
|
}
|
||||||
id="selected-category-description"
|
id="selected-category-description"
|
||||||
class="-mt-3 text-sm text-base-content/60"
|
class="-mt-3 text-sm text-base-content/60"
|
||||||
>
|
>
|
||||||
{category_description(@selected_category)}
|
{category_description(
|
||||||
|
@selected_category,
|
||||||
|
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
:if={@structured_fields != []}
|
:if={@structured_fields != []}
|
||||||
class="space-y-4 rounded-2xl border border-success/30 bg-success/5 p-5"
|
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}>
|
<div :for={field <- @structured_fields}>
|
||||||
<label class="fieldset">
|
<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
|
<select
|
||||||
:if={field["type"] == "select"}
|
:if={field["type"] == "select"}
|
||||||
name={"help_request[structured_data][#{field["key"]}]"}
|
name={"help_request[structured_data][#{field["key"]}]"}
|
||||||
required={field["required"] == true}
|
required={field["required"] == true}
|
||||||
class="select w-full"
|
class="select w-full"
|
||||||
>
|
>
|
||||||
<option value="">Choose…</option>
|
<option value="">{gettext("Choose…")}</option>
|
||||||
<option
|
<option
|
||||||
:for={option <- field["options"] || []}
|
:for={option <- field["options"] || []}
|
||||||
value={option_value(option)}
|
value={option_value(option)}
|
||||||
|
|
@ -201,7 +227,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
to_string(option_value(option))
|
to_string(option_value(option))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{option_label(option, @current_scope.user.locale)}
|
{option_label(option, Gettext.get_locale(WhoNeedHelpWeb.Gettext))}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
|
|
@ -219,12 +245,12 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
required={field["required"] == true}
|
required={field["required"] == true}
|
||||||
class="select w-full"
|
class="select w-full"
|
||||||
>
|
>
|
||||||
<option value="">Choose…</option>
|
<option value="">{gettext("Choose…")}</option>
|
||||||
<option value="true" selected={structured_value(@form, field["key"]) == "true"}>
|
<option value="true" selected={structured_value(@form, field["key"]) == "true"}>
|
||||||
Yes
|
{gettext("Yes")}
|
||||||
</option>
|
</option>
|
||||||
<option value="false" selected={structured_value(@form, field["key"]) == "false"}>
|
<option value="false" selected={structured_value(@form, field["key"]) == "false"}>
|
||||||
No
|
{gettext("No")}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -235,44 +261,58 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
</div>
|
</div>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:title]}
|
field={@form[:title]}
|
||||||
label="Short title"
|
label={gettext("Short title")}
|
||||||
placeholder={category_title_placeholder(@selected_category)}
|
placeholder={category_title_placeholder(@selected_category)}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:description]}
|
field={@form[:description]}
|
||||||
type="textarea"
|
type="textarea"
|
||||||
label="What help do you need?"
|
label={gettext("What help do you need?")}
|
||||||
placeholder="Explain what is already arranged and what the volunteer needs to do."
|
placeholder={
|
||||||
|
gettext("Explain what is already arranged and what the volunteer needs to do.")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:pickup_instructions]}
|
field={@form[:pickup_instructions]}
|
||||||
type="textarea"
|
type="textarea"
|
||||||
label="Pickup instructions (optional)"
|
label={gettext("Pickup instructions (optional)")}
|
||||||
placeholder="Share only non-sensitive instructions. Exact details can wait for the matched chat."
|
placeholder={
|
||||||
|
gettext(
|
||||||
|
"Share only non-sensitive instructions. Exact details can wait for the matched chat."
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<.input
|
<.input
|
||||||
field={@form[:urgency]}
|
field={@form[:urgency]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Urgency"
|
label={gettext("Urgency")}
|
||||||
options={[{"Now", "now"}, {"Today", "today"}, {"Scheduled", "scheduled"}]}
|
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>
|
</div>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:location_label]}
|
field={@form[:location_label]}
|
||||||
label="Approximate area"
|
label={gettext("Approximate area")}
|
||||||
placeholder="Podil, near the central pharmacy"
|
placeholder={gettext("Podil, near the central pharmacy")}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:location_visibility]}
|
field={@form[:location_visibility]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Location visibility"
|
label={gettext("Location visibility")}
|
||||||
options={[
|
options={[
|
||||||
{"Approximate publicly (recommended)", "approximate_public"},
|
{gettext("Approximate publicly (recommended)"), "approximate_public"},
|
||||||
{"Hidden", "hidden"},
|
{gettext("Hidden"), "hidden"},
|
||||||
{"Exact only for active match", "exact_for_active_match"},
|
{gettext("Exact only for active match"), "exact_for_active_match"},
|
||||||
{"Exact publicly — explicit consent", "exact_public"}
|
{gettext("Exact publicly — explicit consent"), "exact_public"}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div class="rounded-2xl bg-base-200 p-5">
|
<div class="rounded-2xl bg-base-200 p-5">
|
||||||
|
|
@ -282,10 +322,10 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
id="location-picker"
|
id="location-picker"
|
||||||
class="btn btn-outline btn-sm"
|
class="btn btn-outline btn-sm"
|
||||||
>
|
>
|
||||||
Use my current foreground location
|
{gettext("Use my current foreground location")}
|
||||||
</button>
|
</button>
|
||||||
<p class="mt-2 text-xs text-base-content/60">
|
<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>
|
</p>
|
||||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -293,14 +333,14 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
id="request-latitude"
|
id="request-latitude"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
label="Latitude"
|
label={gettext("Latitude")}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:longitude]}
|
field={@form[:longitude]}
|
||||||
id="request-longitude"
|
id="request-longitude"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
label="Longitude"
|
label={gettext("Longitude")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -308,7 +348,12 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
||||||
<input type="checkbox" required class="checkbox checkbox-success mt-0.5" />
|
<input type="checkbox" required class="checkbox checkbox-success mt-0.5" />
|
||||||
<span>{safety_confirmation(@selected_category)}</span>
|
<span>{safety_confirmation(@selected_category)}</span>
|
||||||
</label>
|
</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>
|
</.form>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
{:error, :not_found} ->
|
{:error, :not_found} ->
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, "This request is unavailable.")
|
|> put_flash(:error, gettext("This request is unavailable."))
|
||||||
|> push_navigate(to: ~p"/requests")}
|
|> push_navigate(to: ~p"/requests")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -49,7 +49,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
{:error, :not_found} ->
|
{:error, :not_found} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
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")}
|
|> push_navigate(to: ~p"/requests")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -89,7 +89,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
def handle_event("accept", _, socket) do
|
def handle_event("accept", _, socket) do
|
||||||
case Help.accept_request(socket.assigns.current_scope, socket.assigns.request.id) do
|
case Help.accept_request(socket.assigns.current_scope, socket.assigns.request.id) do
|
||||||
{:ok, _} ->
|
{: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} ->
|
{:error, reason} ->
|
||||||
{:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
|
|
@ -99,7 +100,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
def handle_event("cancel-request", _, socket) do
|
def handle_event("cancel-request", _, socket) do
|
||||||
case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id) do
|
case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id) do
|
||||||
{:ok, _request} ->
|
{:ok, _request} ->
|
||||||
{:noreply, put_flash(socket, :info, "Request cancelled.")}
|
{:noreply, put_flash(socket, :info, gettext("Request cancelled."))}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
|
|
@ -107,18 +108,19 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("start", _, socket),
|
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),
|
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),
|
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
|
def handle_event("verify-code", %{"handover" => %{"code" => code}}, socket) do
|
||||||
case Help.verify_handover(socket.assigns.current_scope, socket.assigns.assignment.id, code) do
|
case Help.verify_handover(socket.assigns.current_scope, socket.assigns.assignment.id, code) do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
{:noreply, put_flash(socket, :info, "Handover code verified.")}
|
{:noreply, put_flash(socket, :info, gettext("Handover code verified."))}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
|
|
@ -163,7 +165,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("location-error", _, socket) do
|
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
|
end
|
||||||
|
|
||||||
def handle_event("stop-tracking", _, socket) do
|
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
|
case Trust.submit_review(socket.assigns.current_scope, socket.assigns.assignment, params) do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
{:noreply,
|
{: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} ->
|
{:error, reason} ->
|
||||||
{:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
|
|
@ -210,7 +216,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
socket
|
socket
|
||||||
|> assign(:report_form, report_form())
|
|> assign(:report_form, report_form())
|
||||||
|> assign(:report_message_id, nil)
|
|> assign(:report_message_id, nil)
|
||||||
|> put_flash(:info, "Report sent to moderators.")}
|
|> put_flash(:info, gettext("Report sent to moderators."))}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
|
|
@ -226,7 +232,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
if message do
|
if message do
|
||||||
{:noreply, assign(socket, :report_message_id, message.id)}
|
{:noreply, assign(socket, :report_message_id, message.id)}
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -246,7 +253,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
:markers,
|
:markers,
|
||||||
Jason.encode!(request_markers(socket.assigns.current_scope, socket.assigns.request))
|
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} ->
|
{:error, reason} ->
|
||||||
{:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
|
|
@ -259,7 +269,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:blocked_by_current, false)
|
|> assign(:blocked_by_current, false)
|
||||||
|> put_flash(:info, "User unblocked.")}
|
|> put_flash(:info, gettext("User unblocked."))}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
|
|
@ -335,7 +345,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
|> assign(:request, request)
|
|> assign(:request, request)
|
||||||
|> assign(
|
|> assign(
|
||||||
:structured_details,
|
: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(:requester_reputation, Trust.reputation(request.requester_id))
|
||||||
|> assign(
|
|> assign(
|
||||||
|
|
@ -418,8 +428,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
value ->
|
value ->
|
||||||
label = localized(field["label"], locale) || field["key"]
|
label = localized(field["label"], locale) || field["key"]
|
||||||
|
|
||||||
display =
|
[{label, structured_value(field, value, locale)}]
|
||||||
if field["type"] == "select" do
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp structured_value(%{"type" => "select"} = field, value, locale) do
|
||||||
option =
|
option =
|
||||||
Enum.find(field["options"] || [], fn
|
Enum.find(field["options"] || [], fn
|
||||||
%{"value" => candidate} -> to_string(candidate) == to_string(value)
|
%{"value" => candidate} -> to_string(candidate) == to_string(value)
|
||||||
|
|
@ -431,14 +445,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
nil -> to_string(value)
|
nil -> to_string(value)
|
||||||
candidate -> to_string(candidate)
|
candidate -> to_string(candidate)
|
||||||
end
|
end
|
||||||
else
|
|
||||||
to_string(value)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
[{label, display}]
|
defp structured_value(%{"type" => "boolean"}, value, _locale)
|
||||||
end
|
when value in [true, "true", "1"],
|
||||||
end)
|
do: gettext("Yes")
|
||||||
end
|
|
||||||
|
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),
|
defp localized(labels, locale) when is_map(labels),
|
||||||
do: labels[to_string(locale)] || labels["en"]
|
do: labels[to_string(locale)] || labels["en"]
|
||||||
|
|
@ -466,32 +480,65 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
latitude: point.latitude,
|
latitude: point.latitude,
|
||||||
longitude: point.longitude,
|
longitude: point.longitude,
|
||||||
exact: true,
|
exact: true,
|
||||||
title: "Shared live location",
|
title: gettext("Shared live location"),
|
||||||
location: "Active match only"
|
location: gettext("Active match only")
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp message(:own_request), do: "You cannot accept your own request."
|
defp message(:own_request), do: gettext("You cannot accept your own request.")
|
||||||
defp message(:not_open), do: "Another helper already accepted this request."
|
defp message(:not_open), do: gettext("Another helper already accepted this request.")
|
||||||
defp message(:invalid_code), do: "That handover code is not correct."
|
defp message(:invalid_code), do: gettext("That handover code is not correct.")
|
||||||
defp message(:forbidden), do: "You are not allowed to do that."
|
defp message(:forbidden), do: gettext("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(:invalid_transition),
|
||||||
defp message(:blocked), do: "This interaction is blocked."
|
do: gettext("That step is not available in the current state.")
|
||||||
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(:account_not_eligible),
|
||||||
defp message(reason), do: "Could not complete the action: #{inspect(reason)}"
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<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">
|
<div class="alert alert-warning mt-4 text-sm">
|
||||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
<span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 grid gap-7 lg:grid-cols-[minmax(0,1fr)_25rem]">
|
<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">
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class="badge badge-success">{WhoNeedHelp.Catalog.Category.name(
|
<span class="badge badge-success">{WhoNeedHelp.Catalog.Category.name(
|
||||||
@request.category,
|
@request.category,
|
||||||
@current_scope.user.locale
|
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||||
)}</span>
|
)}</span>
|
||||||
<span class="badge badge-error badge-outline">{@request.urgency}</span>
|
<span class="badge badge-error badge-outline">{urgency_label(@request.urgency)}</span>
|
||||||
<span class="badge">{@request.status}</span>
|
<span class="badge">{status_label(@request.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mt-5 text-4xl font-black tracking-tight">{@request.title}</h1>
|
<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">
|
<p class="mt-5 whitespace-pre-wrap leading-7 text-base-content/75">
|
||||||
{@request.description}
|
{@request.description}
|
||||||
</p>
|
</p>
|
||||||
<div :if={@request.pickup_instructions} class="mt-5 rounded-2xl bg-base-200 p-5">
|
<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>
|
<p class="mt-2 whitespace-pre-wrap text-sm">{@request.pickup_instructions}</p>
|
||||||
</div>
|
</div>
|
||||||
<div :if={@structured_details != []} class="mt-5 rounded-2xl bg-success/10 p-5">
|
<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">
|
<dl class="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||||
<div :for={{label, value} <- @structured_details}>
|
<div :for={{label, value} <- @structured_details}>
|
||||||
<dt class="text-base-content/50">{label}</dt>
|
<dt class="text-base-content/50">{label}</dt>
|
||||||
|
|
@ -523,25 +572,31 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 grid gap-3 border-t border-base-300 pt-5 text-sm sm:grid-cols-2">
|
<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>
|
<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>
|
||||||
<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,
|
@request.expires_at,
|
||||||
"%d %b, %H:%M UTC"
|
"%d.%m.%Y, %H:%M UTC"
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<div
|
<div
|
||||||
:if={@request.requester.social_identities != []}
|
:if={@request.requester.social_identities != []}
|
||||||
class="mt-4 flex flex-wrap items-center gap-2"
|
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
|
<a
|
||||||
:for={identity <- @request.requester.social_identities}
|
:for={identity <- @request.requester.social_identities}
|
||||||
href={identity.profile_url}
|
href={identity.profile_url}
|
||||||
|
|
@ -549,33 +604,37 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
rel="noopener noreferrer nofollow"
|
rel="noopener noreferrer nofollow"
|
||||||
class="badge badge-warning badge-outline"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
||||||
<span class="badge badge-outline">
|
<span class="badge badge-outline">
|
||||||
Requester: {@requester_reputation.completed} completed
|
{gettext("Requester: %{count} completed",
|
||||||
|
count: @requester_reputation.completed
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span class="badge badge-outline">
|
<span class="badge badge-outline">
|
||||||
{@requester_reputation.unique_people} unique people
|
{gettext("%{count} unique people", count: @requester_reputation.unique_people)}
|
||||||
</span>
|
</span>
|
||||||
<span class="badge badge-outline">
|
<span class="badge badge-outline">
|
||||||
rating {@requester_reputation.rating || "—"}
|
{gettext("rating %{rating}", rating: @requester_reputation.rating || "—")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section :if={@participant} class="rounded-3xl border border-base-300 p-6">
|
<section :if={@participant} class="rounded-3xl border border-base-300 p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-xl font-bold">Private match chat</h2>
|
<h2 class="text-xl font-bold">{gettext("Private match chat")}</h2>
|
||||||
<span class="badge badge-success badge-sm">participants only</span>
|
<span class="badge badge-success badge-sm">{gettext("participants only")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="messages"
|
id="messages"
|
||||||
class="mt-5 max-h-80 space-y-3 overflow-y-auto rounded-2xl bg-base-200 p-4"
|
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">
|
<p :if={@messages == []} class="py-6 text-center text-sm text-base-content/50">
|
||||||
No messages yet.
|
{gettext("No messages yet.")}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
:for={message <- @messages}
|
:for={message <- @messages}
|
||||||
|
|
@ -593,7 +652,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
phx-value-id={message.id}
|
phx-value-id={message.id}
|
||||||
class="btn btn-ghost btn-xs mt-1 text-error"
|
class="btn btn-ghost btn-xs mt-1 text-error"
|
||||||
>
|
>
|
||||||
Report message
|
{gettext("Report message")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -605,10 +664,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
>
|
>
|
||||||
<.input
|
<.input
|
||||||
field={@message_form[:body]}
|
field={@message_form[:body]}
|
||||||
placeholder="Write a safe coordination message…"
|
placeholder={gettext("Write a safe coordination message…")}
|
||||||
class="grow"
|
class="grow"
|
||||||
/>
|
/>
|
||||||
<.button class="btn btn-primary self-end">Send</.button>
|
<.button class="btn btn-primary self-end">{gettext("Send")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -616,25 +675,29 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
:if={@assignment && @assignment.status == :completed && @participant}
|
:if={@assignment && @assignment.status == :completed && @participant}
|
||||||
class="rounded-3xl bg-base-200 p-6"
|
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">
|
<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>
|
</p>
|
||||||
<.form for={@review_form} phx-submit="review" class="mt-4 space-y-3">
|
<.form for={@review_form} phx-submit="review" class="mt-4 space-y-3">
|
||||||
<.input
|
<.input
|
||||||
field={@review_form[:rating]}
|
field={@review_form[:rating]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Rating"
|
label={gettext("Rating")}
|
||||||
options={[
|
options={[
|
||||||
{"5 — excellent", 5},
|
{gettext("5 — excellent"), 5},
|
||||||
{"4 — good", 4},
|
{gettext("4 — good"), 4},
|
||||||
{"3 — okay", 3},
|
{gettext("3 — okay"), 3},
|
||||||
{"2 — poor", 2},
|
{gettext("2 — poor"), 2},
|
||||||
{"1 — unsafe", 1}
|
{gettext("1 — unsafe"), 1}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<.input field={@review_form[:comment]} type="textarea" label="Comment (optional)" />
|
<.input
|
||||||
<.button class="btn btn-outline">Submit review</.button>
|
field={@review_form[:comment]}
|
||||||
|
type="textarea"
|
||||||
|
label={gettext("Comment (optional)")}
|
||||||
|
/>
|
||||||
|
<.button class="btn btn-outline">{gettext("Submit review")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -649,36 +712,43 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="rounded-3xl bg-neutral p-6 text-neutral-content">
|
<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 %>
|
<%= cond do %>
|
||||||
<% @request.status == :open && @request.requester_id != @current_scope.user.id -> %>
|
<% @request.status == :open && @request.requester_id != @current_scope.user.id -> %>
|
||||||
<p class="mt-2 text-sm opacity-70">
|
<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>
|
</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 -> %>
|
<% @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
|
<button
|
||||||
:if={Help.requester?(@current_scope, @request)}
|
:if={Help.requester?(@current_scope, @request)}
|
||||||
phx-click="cancel-request"
|
phx-click="cancel-request"
|
||||||
class="btn btn-error btn-outline mt-4 w-full"
|
class="btn btn-error btn-outline mt-4 w-full"
|
||||||
>
|
>
|
||||||
Cancel request
|
{gettext("Cancel request")}
|
||||||
</button>
|
</button>
|
||||||
<% @participant -> %>
|
<% @participant -> %>
|
||||||
<div class="mt-4 space-y-3">
|
<div class="mt-4 space-y-3">
|
||||||
<div class="rounded-xl bg-white/10 p-3 text-sm">
|
<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>
|
||||||
<div class="grid grid-cols-3 gap-2 rounded-xl bg-white/10 p-3 text-center text-xs">
|
<div class="grid grid-cols-3 gap-2 rounded-xl bg-white/10 p-3 text-center text-xs">
|
||||||
<div>
|
<div>
|
||||||
<strong class="block text-lg">{@helper_reputation.completed}</strong>completed
|
<strong class="block text-lg">{@helper_reputation.completed}</strong>
|
||||||
|
{gettext("completed")}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
<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>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2 rounded-xl bg-white/10 p-3 text-xs">
|
<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"
|
is_nil(@assignment.proximity_observed_at) && "badge-ghost"
|
||||||
]}>
|
]}>
|
||||||
{if @assignment.proximity_observed_at,
|
{if @assignment.proximity_observed_at,
|
||||||
do: "proximity supported",
|
do: gettext("proximity supported"),
|
||||||
else: "no proximity evidence"}
|
else: gettext("no proximity evidence")}
|
||||||
</span>
|
</span>
|
||||||
<span class={[
|
<span class={[
|
||||||
"badge badge-sm",
|
"badge badge-sm",
|
||||||
|
|
@ -697,11 +767,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
is_nil(@assignment.helper_movement_observed_at) && "badge-ghost"
|
is_nil(@assignment.helper_movement_observed_at) && "badge-ghost"
|
||||||
]}>
|
]}>
|
||||||
{if @assignment.helper_movement_observed_at,
|
{if @assignment.helper_movement_observed_at,
|
||||||
do: "helper movement supported",
|
do: gettext("helper movement supported"),
|
||||||
else: "no movement evidence"}
|
else: gettext("no movement evidence")}
|
||||||
</span>
|
</span>
|
||||||
<p class="w-full opacity-60">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -716,20 +788,24 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
class="badge badge-warning badge-outline"
|
class="badge badge-warning badge-outline"
|
||||||
>
|
>
|
||||||
{identity.provider} · {if identity.verified_at,
|
{identity.provider} · {if identity.verified_at,
|
||||||
do: "verified",
|
do: gettext("verified"),
|
||||||
else: "unverified"}
|
else: gettext("unverified")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
:if={Help.helper?(@current_scope, @assignment) && @assignment.status == :accepted}
|
:if={Help.helper?(@current_scope, @assignment) && @assignment.status == :accepted}
|
||||||
phx-click="start"
|
phx-click="start"
|
||||||
class="btn btn-success w-full"
|
class="btn btn-success w-full"
|
||||||
>Start helping</button>
|
>
|
||||||
|
{gettext("Start helping")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
:if={@assignment.status in [:accepted, :in_progress]}
|
:if={@assignment.status in [:accepted, :in_progress]}
|
||||||
phx-click="confirm"
|
phx-click="confirm"
|
||||||
class="btn btn-outline w-full"
|
class="btn btn-outline w-full"
|
||||||
>Confirm my part is complete</button>
|
>
|
||||||
|
{gettext("Confirm my part is complete")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
:if={
|
:if={
|
||||||
Help.helper?(@current_scope, @assignment) &&
|
Help.helper?(@current_scope, @assignment) &&
|
||||||
|
|
@ -738,7 +814,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
phx-click="withdraw"
|
phx-click="withdraw"
|
||||||
class="btn btn-error btn-outline w-full"
|
class="btn btn-error btn-outline w-full"
|
||||||
>
|
>
|
||||||
Withdraw from this request
|
{gettext("Withdraw from this request")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:if={
|
:if={
|
||||||
|
|
@ -748,14 +824,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
phx-click="cancel-request"
|
phx-click="cancel-request"
|
||||||
class="btn btn-error btn-outline w-full"
|
class="btn btn-error btn-outline w-full"
|
||||||
>
|
>
|
||||||
Cancel request
|
{gettext("Cancel request")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
:if={Help.requester?(@current_scope, @request)}
|
:if={Help.requester?(@current_scope, @request)}
|
||||||
class="rounded-xl bg-white/10 p-4 text-center"
|
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]">
|
<div class="mt-1 font-mono text-3xl font-black tracking-[.25em]">
|
||||||
{Help.handover_code(@request.id)}
|
{Help.handover_code(@request.id)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -769,8 +847,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
for={@handover_form}
|
for={@handover_form}
|
||||||
phx-submit="verify-code"
|
phx-submit="verify-code"
|
||||||
>
|
>
|
||||||
<.input field={@handover_form[:code]} label="Enter handover code" maxlength="6" />
|
<.input
|
||||||
<.button class="btn btn-primary mt-2 w-full">Verify handover</.button>
|
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>
|
</.form>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
|
|
@ -784,11 +868,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
rel="noopener noreferrer nofollow"
|
rel="noopener noreferrer nofollow"
|
||||||
class="btn btn-outline w-full"
|
class="btn btn-outline w-full"
|
||||||
>
|
>
|
||||||
Optional thank-you link
|
{gettext("Optional thank-you link")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<% true -> %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -796,71 +882,82 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
:if={@participant && @assignment.status in [:accepted, :in_progress]}
|
:if={@participant && @assignment.status in [:accepted, :in_progress]}
|
||||||
class="rounded-3xl border border-base-300 p-5"
|
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">
|
<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
|
{gettext(
|
||||||
persistent notification and can continue after it is minimized. Raw coordinates are
|
"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."
|
||||||
removed when sharing stops.
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div :if={@tracking_active} id="live-tracking" phx-hook="LiveTracking"></div>
|
<div :if={@tracking_active} id="live-tracking" phx-hook="LiveTracking"></div>
|
||||||
<button
|
<button
|
||||||
:if={!@tracking_active}
|
:if={!@tracking_active}
|
||||||
phx-click="start-tracking"
|
phx-click="start-tracking"
|
||||||
class="btn btn-outline btn-sm mt-4 w-full"
|
class="btn btn-outline btn-sm mt-4 w-full"
|
||||||
>Share location</button>
|
>
|
||||||
|
{gettext("Share location")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
:if={@tracking_active}
|
:if={@tracking_active}
|
||||||
phx-click="stop-tracking"
|
phx-click="stop-tracking"
|
||||||
class="btn btn-error btn-sm mt-4 w-full"
|
class="btn btn-error btn-sm mt-4 w-full"
|
||||||
>Stop and delete position</button>
|
>
|
||||||
|
{gettext("Stop and delete position")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :if={@other_user_id} class="rounded-3xl border border-base-300 p-5">
|
<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">
|
<p class="mt-2 text-xs text-base-content/55">
|
||||||
Reports go to moderators. Blocking hides requests in both directions and prevents new
|
{gettext(
|
||||||
chat messages.
|
"Reports go to moderators. Blocking hides requests in both directions and prevents new chat messages."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
:if={!@blocked_by_current}
|
:if={!@blocked_by_current}
|
||||||
phx-click="block-user"
|
phx-click="block-user"
|
||||||
class="btn btn-outline btn-sm mt-4 w-full"
|
class="btn btn-outline btn-sm mt-4 w-full"
|
||||||
>
|
>
|
||||||
Block this user
|
{gettext("Block this user")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:if={@blocked_by_current}
|
:if={@blocked_by_current}
|
||||||
phx-click="unblock-user"
|
phx-click="unblock-user"
|
||||||
class="btn btn-outline btn-sm mt-4 w-full"
|
class="btn btn-outline btn-sm mt-4 w-full"
|
||||||
>
|
>
|
||||||
Unblock this user
|
{gettext("Unblock this user")}
|
||||||
</button>
|
</button>
|
||||||
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2">
|
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2">
|
||||||
<div
|
<div
|
||||||
:if={@report_message_id}
|
:if={@report_message_id}
|
||||||
class="flex items-center justify-between rounded-xl bg-error/10 p-3 text-xs"
|
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">
|
<button type="button" phx-click="clear-report-message" class="btn btn-ghost btn-xs">
|
||||||
Clear
|
{gettext("Clear")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<.input
|
<.input
|
||||||
field={@report_form[:reason]}
|
field={@report_form[:reason]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Report reason"
|
label={gettext("Report reason")}
|
||||||
options={[
|
options={[
|
||||||
{"Dangerous request", "dangerous_request"},
|
{gettext("Dangerous request"), "dangerous_request"},
|
||||||
{"Harassment", "harassment"},
|
{gettext("Harassment"), "harassment"},
|
||||||
{"Fraud", "fraud"},
|
{gettext("Fraud"), "fraud"},
|
||||||
{"Prohibited item", "prohibited_item"},
|
{gettext("Prohibited item"), "prohibited_item"},
|
||||||
{"Spam", "spam"},
|
{gettext("Spam"), "spam"},
|
||||||
{"Impersonation", "impersonation"},
|
{gettext("Impersonation"), "impersonation"},
|
||||||
{"Other", "other"}
|
{gettext("Other"), "other"}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<.input field={@report_form[:details]} type="textarea" label="What happened?" />
|
<.input
|
||||||
<.button class="btn btn-error btn-outline w-full">Send report</.button>
|
field={@report_form[:details]}
|
||||||
|
type="textarea"
|
||||||
|
label={gettext("What happened?")}
|
||||||
|
/>
|
||||||
|
<.button class="btn btn-error btn-outline w-full">
|
||||||
|
{gettext("Send report")}
|
||||||
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
defmodule WhoNeedHelpWeb.UserAuth do
|
defmodule WhoNeedHelpWeb.UserAuth do
|
||||||
use WhoNeedHelpWeb, :verified_routes
|
use WhoNeedHelpWeb, :verified_routes
|
||||||
|
use Gettext, backend: WhoNeedHelpWeb.Gettext
|
||||||
|
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
import Phoenix.Controller
|
import Phoenix.Controller
|
||||||
|
|
@ -174,7 +175,7 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
||||||
conn
|
conn
|
||||||
else
|
else
|
||||||
conn
|
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()
|
|> maybe_store_return_to()
|
||||||
|> redirect(to: ~p"/users/log-in")
|
|> redirect(to: ~p"/users/log-in")
|
||||||
|> halt()
|
|> halt()
|
||||||
|
|
@ -204,7 +205,7 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
||||||
conn
|
conn
|
||||||
else
|
else
|
||||||
conn
|
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()
|
|> maybe_store_return_to()
|
||||||
|> redirect(to: ~p"/users/log-in")
|
|> redirect(to: ~p"/users/log-in")
|
||||||
|> halt()
|
|> halt()
|
||||||
|
|
@ -220,21 +221,24 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
||||||
@doc "Loads the generated session token into LiveView's current scope."
|
@doc "Loads the generated session token into LiveView's current scope."
|
||||||
def on_mount(:mount_current_scope, _params, session, socket) do
|
def on_mount(:mount_current_scope, _params, session, socket) do
|
||||||
scope = live_scope(session)
|
scope = live_scope(session)
|
||||||
set_live_locale(scope)
|
set_live_locale(scope, session)
|
||||||
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_mount(:ensure_authenticated, _params, session, socket) do
|
def on_mount(:ensure_authenticated, _params, session, socket) do
|
||||||
case live_scope(session) do
|
case live_scope(session) do
|
||||||
%Scope{user: %Accounts.User{}} = scope ->
|
%Scope{user: %Accounts.User{}} = scope ->
|
||||||
set_live_locale(scope)
|
set_live_locale(scope, session)
|
||||||
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> Phoenix.Component.assign(:current_scope, nil)
|
|> 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")
|
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
|
|
@ -245,13 +249,13 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
||||||
case live_scope(session) do
|
case live_scope(session) do
|
||||||
%Scope{user: %Accounts.User{} = user} = scope ->
|
%Scope{user: %Accounts.User{} = user} = scope ->
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.moderator_authorized?(user) do
|
||||||
set_live_locale(scope)
|
set_live_locale(scope, session)
|
||||||
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
||||||
else
|
else
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> Phoenix.Component.assign(:current_scope, scope)
|
|> 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")
|
|> Phoenix.LiveView.redirect(to: ~p"/requests")
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
|
|
@ -261,7 +265,10 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> Phoenix.Component.assign(:current_scope, nil)
|
|> 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")
|
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
|
|
@ -277,9 +284,14 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
||||||
|
|
||||||
defp live_scope(_session), do: nil
|
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)
|
Gettext.put_locale(WhoNeedHelpWeb.Gettext, locale)
|
||||||
end
|
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
|
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}"
|
msgid "must be equal to %{number}"
|
||||||
msgstr ""
|
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}"
|
msgid "must be equal to %{number}"
|
||||||
msgstr ""
|
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.
|
# "msgid"s in this file come from POT (.pot) files.
|
||||||
###
|
# #
|
||||||
### Do not add, change, or remove "msgid"s manually here as
|
# # Do not add, change, or remove "msgid"s manually here as
|
||||||
### they're tied to the ones in the corresponding POT file
|
# # they're tied to the ones in the corresponding POT file
|
||||||
### (with the same domain).
|
# # (with the same domain).
|
||||||
###
|
# #
|
||||||
### Use "mix gettext.extract --merge" or "mix gettext.merge"
|
# # Use "mix gettext.extract --merge" or "mix gettext.merge"
|
||||||
### to merge POT files into PO files.
|
# # to merge POT files into PO files.
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Language: ru\n"
|
"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"
|
"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"
|
msgid "can't be blank"
|
||||||
msgstr ""
|
msgstr "не может быть пустым"
|
||||||
|
|
||||||
msgid "has already been taken"
|
msgid "has already been taken"
|
||||||
msgstr ""
|
msgstr "уже занято"
|
||||||
|
|
||||||
msgid "is invalid"
|
msgid "is invalid"
|
||||||
msgstr ""
|
msgstr "недопустимо"
|
||||||
|
|
||||||
msgid "must be accepted"
|
msgid "must be accepted"
|
||||||
msgstr ""
|
msgstr "необходимо принять"
|
||||||
|
|
||||||
msgid "has invalid format"
|
msgid "has invalid format"
|
||||||
msgstr ""
|
msgstr "имеет неверный формат"
|
||||||
|
|
||||||
msgid "has an invalid entry"
|
msgid "has an invalid entry"
|
||||||
msgstr ""
|
msgstr "содержит недопустимое значение"
|
||||||
|
|
||||||
msgid "is reserved"
|
msgid "is reserved"
|
||||||
msgstr ""
|
msgstr "зарезервировано"
|
||||||
|
|
||||||
msgid "does not match confirmation"
|
msgid "does not match confirmation"
|
||||||
msgstr ""
|
msgstr "не совпадает с подтверждением"
|
||||||
|
|
||||||
msgid "is still associated with this entry"
|
msgid "is still associated with this entry"
|
||||||
msgstr ""
|
msgstr "всё ещё связано с этой записью"
|
||||||
|
|
||||||
msgid "are still associated with this entry"
|
msgid "are still associated with this entry"
|
||||||
msgstr ""
|
msgstr "всё ещё связаны с этой записью"
|
||||||
|
|
||||||
msgid "should have %{count} item(s)"
|
msgid "should have %{count} item(s)"
|
||||||
msgid_plural "should have %{count} item(s)"
|
msgid_plural "should have %{count} item(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "должно содержать %{count} элемент"
|
||||||
msgstr[1] ""
|
msgstr[1] "должно содержать %{count} элемента"
|
||||||
msgstr[2] ""
|
msgstr[2] "должно содержать %{count} элементов"
|
||||||
|
|
||||||
msgid "should be %{count} character(s)"
|
msgid "should be %{count} character(s)"
|
||||||
msgid_plural "should be %{count} character(s)"
|
msgid_plural "should be %{count} character(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "должно содержать %{count} символ"
|
||||||
msgstr[1] ""
|
msgstr[1] "должно содержать %{count} символа"
|
||||||
msgstr[2] ""
|
msgstr[2] "должно содержать %{count} символов"
|
||||||
|
|
||||||
msgid "should be %{count} byte(s)"
|
msgid "should be %{count} byte(s)"
|
||||||
msgid_plural "should be %{count} byte(s)"
|
msgid_plural "should be %{count} byte(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "должно занимать %{count} байт"
|
||||||
msgstr[1] ""
|
msgstr[1] "должно занимать %{count} байта"
|
||||||
msgstr[2] ""
|
msgstr[2] "должно занимать %{count} байтов"
|
||||||
|
|
||||||
msgid "should have at least %{count} item(s)"
|
msgid "should have at least %{count} item(s)"
|
||||||
msgid_plural "should have at least %{count} item(s)"
|
msgid_plural "should have at least %{count} item(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "должно содержать не менее %{count} элемента"
|
||||||
msgstr[1] ""
|
msgstr[1] "должно содержать не менее %{count} элементов"
|
||||||
msgstr[2] ""
|
msgstr[2] "должно содержать не менее %{count} элементов"
|
||||||
|
|
||||||
msgid "should be at least %{count} character(s)"
|
msgid "should be at least %{count} character(s)"
|
||||||
msgid_plural "should be at least %{count} character(s)"
|
msgid_plural "should be at least %{count} character(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "должно содержать не менее %{count} символа"
|
||||||
msgstr[1] ""
|
msgstr[1] "должно содержать не менее %{count} символов"
|
||||||
msgstr[2] ""
|
msgstr[2] "должно содержать не менее %{count} символов"
|
||||||
|
|
||||||
msgid "should be at least %{count} byte(s)"
|
msgid "should be at least %{count} byte(s)"
|
||||||
msgid_plural "should be at least %{count} byte(s)"
|
msgid_plural "should be at least %{count} byte(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "должно занимать не менее %{count} байта"
|
||||||
msgstr[1] ""
|
msgstr[1] "должно занимать не менее %{count} байтов"
|
||||||
msgstr[2] ""
|
msgstr[2] "должно занимать не менее %{count} байтов"
|
||||||
|
|
||||||
msgid "should have at most %{count} item(s)"
|
msgid "should have at most %{count} item(s)"
|
||||||
msgid_plural "should have at most %{count} item(s)"
|
msgid_plural "should have at most %{count} item(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "должно содержать не более %{count} элемента"
|
||||||
msgstr[1] ""
|
msgstr[1] "должно содержать не более %{count} элементов"
|
||||||
msgstr[2] ""
|
msgstr[2] "должно содержать не более %{count} элементов"
|
||||||
|
|
||||||
msgid "should be at most %{count} character(s)"
|
msgid "should be at most %{count} character(s)"
|
||||||
msgid_plural "should be at most %{count} character(s)"
|
msgid_plural "should be at most %{count} character(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "должно содержать не более %{count} символа"
|
||||||
msgstr[1] ""
|
msgstr[1] "должно содержать не более %{count} символов"
|
||||||
msgstr[2] ""
|
msgstr[2] "должно содержать не более %{count} символов"
|
||||||
|
|
||||||
msgid "should be at most %{count} byte(s)"
|
msgid "should be at most %{count} byte(s)"
|
||||||
msgid_plural "should be at most %{count} byte(s)"
|
msgid_plural "should be at most %{count} byte(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "должно занимать не более %{count} байта"
|
||||||
msgstr[1] ""
|
msgstr[1] "должно занимать не более %{count} байтов"
|
||||||
msgstr[2] ""
|
msgstr[2] "должно занимать не более %{count} байтов"
|
||||||
|
|
||||||
msgid "must be less than %{number}"
|
msgid "must be less than %{number}"
|
||||||
msgstr ""
|
msgstr "должно быть меньше %{number}"
|
||||||
|
|
||||||
msgid "must be greater than %{number}"
|
msgid "must be greater than %{number}"
|
||||||
msgstr ""
|
msgstr "должно быть больше %{number}"
|
||||||
|
|
||||||
msgid "must be less than or equal to %{number}"
|
msgid "must be less than or equal to %{number}"
|
||||||
msgstr ""
|
msgstr "должно быть не больше %{number}"
|
||||||
|
|
||||||
msgid "must be greater than or equal to %{number}"
|
msgid "must be greater than or equal to %{number}"
|
||||||
msgstr ""
|
msgstr "должно быть не меньше %{number}"
|
||||||
|
|
||||||
msgid "must be equal to %{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.
|
# "msgid"s in this file come from POT (.pot) files.
|
||||||
###
|
# #
|
||||||
### Do not add, change, or remove "msgid"s manually here as
|
# # Do not add, change, or remove "msgid"s manually here as
|
||||||
### they're tied to the ones in the corresponding POT file
|
# # they're tied to the ones in the corresponding POT file
|
||||||
### (with the same domain).
|
# # (with the same domain).
|
||||||
###
|
# #
|
||||||
### Use "mix gettext.extract --merge" or "mix gettext.merge"
|
# # Use "mix gettext.extract --merge" or "mix gettext.merge"
|
||||||
### to merge POT files into PO files.
|
# # to merge POT files into PO files.
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Language: uk\n"
|
"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"
|
"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"
|
msgid "can't be blank"
|
||||||
msgstr ""
|
msgstr "не може бути порожнім"
|
||||||
|
|
||||||
msgid "has already been taken"
|
msgid "has already been taken"
|
||||||
msgstr ""
|
msgstr "вже зайнято"
|
||||||
|
|
||||||
msgid "is invalid"
|
msgid "is invalid"
|
||||||
msgstr ""
|
msgstr "неприпустимо"
|
||||||
|
|
||||||
msgid "must be accepted"
|
msgid "must be accepted"
|
||||||
msgstr ""
|
msgstr "необхідно прийняти"
|
||||||
|
|
||||||
msgid "has invalid format"
|
msgid "has invalid format"
|
||||||
msgstr ""
|
msgstr "має неправильний формат"
|
||||||
|
|
||||||
msgid "has an invalid entry"
|
msgid "has an invalid entry"
|
||||||
msgstr ""
|
msgstr "містить неприпустиме значення"
|
||||||
|
|
||||||
msgid "is reserved"
|
msgid "is reserved"
|
||||||
msgstr ""
|
msgstr "зарезервовано"
|
||||||
|
|
||||||
msgid "does not match confirmation"
|
msgid "does not match confirmation"
|
||||||
msgstr ""
|
msgstr "не збігається з підтвердженням"
|
||||||
|
|
||||||
msgid "is still associated with this entry"
|
msgid "is still associated with this entry"
|
||||||
msgstr ""
|
msgstr "досі пов’язано з цим записом"
|
||||||
|
|
||||||
msgid "are still associated with this entry"
|
msgid "are still associated with this entry"
|
||||||
msgstr ""
|
msgstr "досі пов’язані з цим записом"
|
||||||
|
|
||||||
msgid "should have %{count} item(s)"
|
msgid "should have %{count} item(s)"
|
||||||
msgid_plural "should have %{count} item(s)"
|
msgid_plural "should have %{count} item(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "має містити %{count} елемент"
|
||||||
msgstr[1] ""
|
msgstr[1] "має містити %{count} елементи"
|
||||||
msgstr[2] ""
|
msgstr[2] "має містити %{count} елементів"
|
||||||
|
|
||||||
msgid "should be %{count} character(s)"
|
msgid "should be %{count} character(s)"
|
||||||
msgid_plural "should be %{count} character(s)"
|
msgid_plural "should be %{count} character(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "має містити %{count} символ"
|
||||||
msgstr[1] ""
|
msgstr[1] "має містити %{count} символи"
|
||||||
msgstr[2] ""
|
msgstr[2] "має містити %{count} символів"
|
||||||
|
|
||||||
msgid "should be %{count} byte(s)"
|
msgid "should be %{count} byte(s)"
|
||||||
msgid_plural "should be %{count} byte(s)"
|
msgid_plural "should be %{count} byte(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "має займати %{count} байт"
|
||||||
msgstr[1] ""
|
msgstr[1] "має займати %{count} байти"
|
||||||
msgstr[2] ""
|
msgstr[2] "має займати %{count} байтів"
|
||||||
|
|
||||||
msgid "should have at least %{count} item(s)"
|
msgid "should have at least %{count} item(s)"
|
||||||
msgid_plural "should have at least %{count} item(s)"
|
msgid_plural "should have at least %{count} item(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "має містити щонайменше %{count} елемент"
|
||||||
msgstr[1] ""
|
msgstr[1] "має містити щонайменше %{count} елементи"
|
||||||
msgstr[2] ""
|
msgstr[2] "має містити щонайменше %{count} елементів"
|
||||||
|
|
||||||
msgid "should be at least %{count} character(s)"
|
msgid "should be at least %{count} character(s)"
|
||||||
msgid_plural "should be at least %{count} character(s)"
|
msgid_plural "should be at least %{count} character(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "має містити щонайменше %{count} символ"
|
||||||
msgstr[1] ""
|
msgstr[1] "має містити щонайменше %{count} символи"
|
||||||
msgstr[2] ""
|
msgstr[2] "має містити щонайменше %{count} символів"
|
||||||
|
|
||||||
msgid "should be at least %{count} byte(s)"
|
msgid "should be at least %{count} byte(s)"
|
||||||
msgid_plural "should be at least %{count} byte(s)"
|
msgid_plural "should be at least %{count} byte(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "має займати щонайменше %{count} байт"
|
||||||
msgstr[1] ""
|
msgstr[1] "має займати щонайменше %{count} байти"
|
||||||
msgstr[2] ""
|
msgstr[2] "має займати щонайменше %{count} байтів"
|
||||||
|
|
||||||
msgid "should have at most %{count} item(s)"
|
msgid "should have at most %{count} item(s)"
|
||||||
msgid_plural "should have at most %{count} item(s)"
|
msgid_plural "should have at most %{count} item(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "має містити не більше %{count} елемента"
|
||||||
msgstr[1] ""
|
msgstr[1] "має містити не більше %{count} елементів"
|
||||||
msgstr[2] ""
|
msgstr[2] "має містити не більше %{count} елементів"
|
||||||
|
|
||||||
msgid "should be at most %{count} character(s)"
|
msgid "should be at most %{count} character(s)"
|
||||||
msgid_plural "should be at most %{count} character(s)"
|
msgid_plural "should be at most %{count} character(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "має містити не більше %{count} символу"
|
||||||
msgstr[1] ""
|
msgstr[1] "має містити не більше %{count} символів"
|
||||||
msgstr[2] ""
|
msgstr[2] "має містити не більше %{count} символів"
|
||||||
|
|
||||||
msgid "should be at most %{count} byte(s)"
|
msgid "should be at most %{count} byte(s)"
|
||||||
msgid_plural "should be at most %{count} byte(s)"
|
msgid_plural "should be at most %{count} byte(s)"
|
||||||
msgstr[0] ""
|
msgstr[0] "має займати не більше %{count} байта"
|
||||||
msgstr[1] ""
|
msgstr[1] "має займати не більше %{count} байтів"
|
||||||
msgstr[2] ""
|
msgstr[2] "має займати не більше %{count} байтів"
|
||||||
|
|
||||||
msgid "must be less than %{number}"
|
msgid "must be less than %{number}"
|
||||||
msgstr ""
|
msgstr "має бути менше за %{number}"
|
||||||
|
|
||||||
msgid "must be greater than %{number}"
|
msgid "must be greater than %{number}"
|
||||||
msgstr ""
|
msgstr "має бути більше за %{number}"
|
||||||
|
|
||||||
msgid "must be less than or equal to %{number}"
|
msgid "must be less than or equal to %{number}"
|
||||||
msgstr ""
|
msgstr "має бути не більше за %{number}"
|
||||||
|
|
||||||
msgid "must be greater than or equal to %{number}"
|
msgid "must be greater than or equal to %{number}"
|
||||||
msgstr ""
|
msgstr "має бути не менше за %{number}"
|
||||||
|
|
||||||
msgid "must be equal to %{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) =~ "Помощь может быть ближе, чем кажется."
|
assert html_response(conn, 200) =~ "Помощь может быть ближе, чем кажется."
|
||||||
end
|
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
|
test "GET /safety", %{conn: conn} do
|
||||||
conn = get(conn, ~p"/safety")
|
conn = get(conn, ~p"/safety")
|
||||||
assert html_response(conn, 200) =~ "Safety rules"
|
assert html_response(conn, 200) =~ "Safety rules"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
assert html =~ "Repeated help between the same pair"
|
assert html =~ "Repeated help between the same pair"
|
||||||
end
|
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
|
test "new request form is driven by category structured fields", %{conn: conn} do
|
||||||
category = Catalog.seed_defaults()
|
category = Catalog.seed_defaults()
|
||||||
{:ok, view, html} = live(conn, ~p"/requests/new")
|
{:ok, view, html} = live(conn, ~p"/requests/new")
|
||||||
|
|
@ -26,9 +34,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
assert html =~ "Medicine pickup status"
|
assert html =~ "Medicine pickup status"
|
||||||
|
assert html =~ "Pickup and deliver a legal medicine"
|
||||||
assert html =~ "help_request[structured_data][pickup_status]"
|
assert html =~ "help_request[structured_data][pickup_status]"
|
||||||
end
|
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
|
test "new request form renders hierarchical roadside fields and safety copy", %{conn: conn} do
|
||||||
Catalog.seed_defaults()
|
Catalog.seed_defaults()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user