diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index f94044d..c01bce1 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -3,7 +3,14 @@ # public constructors are used. Keep these filters path- and warning-specific; # `list_unused_filters: true` makes Dialyxir fail when either workaround is no # longer needed. +# +# Gettext compiles the non-English three-form plural rule into the generated +# backend module. Dialyzer sees Expo.PluralForms.plural_ast/0 through +# Gettext.Plural.plural/2 as an opaque-value mismatch even though both values +# are constructed by those libraries. This filter is limited to that generated +# backend path and the same warning class. [ {"lib/who_need_help/help.ex", :call_without_opaque}, - {"lib/who_need_help/tracking.ex", :call_without_opaque} + {"lib/who_need_help/tracking.ex", :call_without_opaque}, + {"lib/who_need_help_web/gettext.ex", :call_without_opaque} ] diff --git a/README.md b/README.md index c93f736..60ee780 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,9 @@ group chat, message-scoped reporting, blocking, privacy defaults, an unverified social link, category moderation, report resolution, and account restriction. The same gate checks keyboard skip navigation, WCAG violations and contrast on four public pages in both themes, horizontal overflow at three viewport widths, -an actual locally served raster map tile, and LiveView offline/reconnect UI. +an actual locally served raster map tile, LiveView offline/reconnect UI, public +RU/UK locale persistence, and the selected Ukrainian locale in an authenticated +LiveView. An optional list of Playwright spec paths can be passed after the command for a focused diagnostic run. It retains traces, screenshots, video, and Compose logs under the ignored diff --git a/docs/local-hardening-plan.md b/docs/local-hardening-plan.md index 3fb2789..697e6c0 100644 --- a/docs/local-hardening-plan.md +++ b/docs/local-hardening-plan.md @@ -10,13 +10,13 @@ item below unless the evidence column explicitly describes a local mock. | Browser E2E | Manual headed-Chrome scenarios exist; no committed browser suite | A fresh uniquely named Compose project runs two-user urgent help, Activity, moderation, privacy, and error paths; traces are retained on failure; its exact volume is removed | | Android UI | Two JVM unit-test files; no `androidTest` source set | Emulator instrumentation covers deep links, permissions, foreground tracking, notification Stop, lifecycle, and network failure | | CI and quality | No tracked CI workflow or static/security analysis dependencies | The same containerized gates pass locally and are represented in a validated CI workflow | -| Localization and accessibility | Accessibility/keyboard/responsive/contrast browser gates pass; most product copy remains hard-coded English | EN/UK/RU catalogs cover product UI; extraction is current; the existing browser gates remain green | +| Localization and accessibility | Completed locally: product copy and custom validation messages are extracted; EN/UK/RU catalogs and localized category descriptions/structured values are implemented | 508 default and 40 error messages are current; RU/UK have no empty/fuzzy entries; 153 backend tests and all 8 browser specs pass, including locale persistence, keyboard, axe, themes, responsive widths, and reconnect | | Database scale | Core discovery/chat/moderation lists call unbounded `Repo.all()` | Cursor-bounded queries pass behavior tests and measured `EXPLAIN ANALYZE` checks on an isolated generated dataset | | Load and resilience | Public/readiness/heartbeat k6 profile exists | Authenticated writes, chat, tracking, reconnect, rolling replacement, and worker retry profiles pass without touching staging data | | Observability | Protected Prometheus text endpoint exists | Local Prometheus/Grafana/Alertmanager profile scrapes every replica and an induced isolated failure exercises alert delivery | | Backup | Validated local custom-format dump and restore drill exist | An encrypted artifact is uploaded to local S3-compatible MinIO and restored into a fresh database; corruption and interrupted-upload checks fail closed | | External boundaries | Mailpit and a fake GitHub strategy cover parts of SMTP/OAuth | Local protocol-level SMTP/OAuth mocks and the applicable push adapter boundary cover success, rejection, retry, replay, and timeout | -| Final regression | 150 Phoenix tests and manual cross-client evidence | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits | +| Final regression | 153 Phoenix tests plus reproducible browser and Android device suites | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits | The goal remains open while any row lacks reproducible local evidence. @@ -55,7 +55,7 @@ The goal remains open while any row lacks reproducible local evidence. - The containerized `scripts/quality.sh` gate passes ShellCheck, Hadolint, actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and the rendered Kubernetes manifest, compiler/xref/Credo/Sobelow/Dialyzer/Hex - checks, 150 Phoenix tests, both npm audits, and a Trivy scan of the production + checks, 153 Phoenix tests, both npm audits, and a Trivy scan of the production release image. It creates random one-run database credentials and removes its exact volume, networks, images, and source snapshot. - The checked-in GitHub Actions workflow runs the same isolated backend/security @@ -66,6 +66,10 @@ The goal remains open while any row lacks reproducible local evidence. Mailpit, both web replicas, and both worker replicas became Ready as non-root containers with read-only root filesystems, dropped capabilities, and RuntimeDefault seccomp where configured. The cross-node PubSub probe passed. -- The remaining rows above are still pending. In particular, complete EN/UK/RU - extraction and catalogs are not implied by the completed accessibility - checks; this document is not a completion claim. +- EN/UK/RU extraction and catalogs are now complete for current product UI and + custom validation messages. Locale selection persists for public pages and + authenticated LiveViews; category descriptions and structured values follow + that browser locale. The localization browser specs run in the same isolated + two-web/two-worker gate as the accessibility checks. +- The remaining rows above are still pending; this document is not a + completion claim for the entire hardening goal. diff --git a/docs/verification.md b/docs/verification.md index 350fce0..d768da3 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -16,27 +16,29 @@ results from product limits and unknown production properties. | Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. | | Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. | | Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. | -| Social profiles | Manual links implemented; GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record; 150 tests pass, including callback replay/state checks. No access-token field exists and the controller receives only normalized identity attributes. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. Other providers remain manual/unverified. | +| Social profiles | Manual links implemented; GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record; 153 tests pass, including callback replay/state checks. No access-token field exists and the controller receives only normalized identity attributes. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. Other providers remain manual/unverified. | | Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. | | Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Public HTTPS login, map, two-way chat, permission prompts, minimized foreground-service location updates, notification Stop, deep-link routing, and server cleanup were exercised on API 37. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. | | Multiple web/worker instances | Implemented and locally verified | Docker Compose and kind each ran 2 web and 2 worker replicas. The project probes cross-node Phoenix PubSub using different BEAM nodes. Kubernetes web/worker pods were Ready with zero restarts at the final observation. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. | ## Reproducible checks -- `./scripts/test.sh`: 150 tests, 0 failures after the Android public-staging and - realtime tracking-evidence changes +- The isolated `./scripts/quality.sh` run completed on 2026-07-19 with 153 + tests and 0 failures after the full localization changes on Elixir 1.20.2 and Erlang/OTP 29.0.3. - `mix compile --force --warnings-as-errors` and `mix format --check-formatted`: passed against the same final source. - `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning threshold, actionlint 1.7.12, all four Compose renders, Helm lint, Trivy source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow - strict/private checks, Hex audit, 150 Phoenix tests, both npm audits, and the + strict/private checks, Hex audit, 153 Phoenix tests, both npm audits, and the production-image vulnerability scan. The rendered Helm manifest and Debian 13.6 release image each reported zero HIGH/CRITICAL findings under the configured gates. -- Dialyzer passed with two exact, documented filters and zero unused filters. - Both findings are opaque-type warnings at Ecto `Multi`/`MapSet` call sites; +- Dialyzer passed with three path- and warning-specific documented filters and + zero unused filters. Two findings are opaque-type warnings at Ecto + `Multi`/`MapSet` call sites; the third is the generated Gettext backend's + three-form plural-rule opaque-value warning; the filters are visible in `.dialyzer_ignore.exs` rather than hidden by a broad pattern. This is not a claim that third-party dependency compilation is warning-free. @@ -64,13 +66,23 @@ results from product limits and unknown production properties. containers also used read-only root filesystems and dropped capabilities. The cross-node probe observed all four BEAM nodes, and live/readiness returned `ok`/`ready`. Database counts remained `0 users / 0 help requests`. -- The committed browser suite passed its 1/1 bootstrap and all 6/6 Chromium - specs against a fresh PostGIS volume with two web and two worker replicas. +- The committed browser suite passed its 1/1 bootstrap and all 8/8 Chromium + specs against a fresh PostGIS volume with two web and two worker replicas on + 2026-07-19. The retained successful-run artifact directory is + `output/e2e/20260719012404-847451`. It covered axe checks across four pages and two themes, keyboard navigation, three responsive widths, Activity/moderation/privacy, the two-user medicine handover and blind reviews, public boundaries, local raster maps, and an - offline LiveSocket transport failure/reconnect. The uniquely named Compose - volume and networks were removed. + offline LiveSocket transport failure/reconnect. It also verified persistent + Russian/Ukrainian public language selection and the selected Ukrainian + locale inside an authenticated LiveView. The uniquely named Compose volume + and networks were removed. +- The default localization domain contains 508 current messages and the errors + domain contains 40. Russian and Ukrainian catalogs have no empty or fuzzy + current entries. Category names, descriptions, structured-field labels and + select/boolean values use locale maps with an English fallback; the + compatibility `description` column remains as a fallback for pre-existing + categories. - Browser verification used headed Chrome. The authenticated matched-request page rendered its chat, MapLibre marker, and live-location controls with no console errors or warnings after the foreground-service rollout. diff --git a/e2e/tests/localization.spec.ts b/e2e/tests/localization.spec.ts new file mode 100644 index 0000000..983bbf1 --- /dev/null +++ b/e2e/tests/localization.spec.ts @@ -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(); +}); diff --git a/lib/who_need_help/accounts/user.ex b/lib/who_need_help/accounts/user.ex index 8ad5fc6..b7d3076 100644 --- a/lib/who_need_help/accounts/user.ex +++ b/lib/who_need_help/accounts/user.ex @@ -56,8 +56,9 @@ defmodule WhoNeedHelp.Accounts.User do def registration_changeset(user, attrs, opts \\ []) do user - |> cast(attrs, [:email, :display_name, :terms_accepted]) - |> validate_required([:display_name]) + |> cast(attrs, [:email, :display_name, :terms_accepted, :locale]) + |> validate_required([:display_name, :locale]) + |> validate_inclusion(:locale, ~w(en uk ru)) |> validate_acceptance(:terms_accepted, message: "you must confirm that you are 18+ and accept the rules" ) diff --git a/lib/who_need_help/accounts/user_notifier.ex b/lib/who_need_help/accounts/user_notifier.ex index 871edba..a6749dc 100644 --- a/lib/who_need_help/accounts/user_notifier.ex +++ b/lib/who_need_help/accounts/user_notifier.ex @@ -1,4 +1,6 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do + use Gettext, backend: WhoNeedHelpWeb.Gettext + import Swoosh.Email alias WhoNeedHelp.Mailer @@ -24,20 +26,17 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do Deliver instructions to update a user email. """ def deliver_update_email_instructions(user, url) do - deliver(user.email, "Update email instructions", """ - - ============================== - - Hi #{user.email}, - - You can change your email by visiting the URL below: - - #{url} - - If you didn't request this change, please ignore this. - - ============================== - """) + with_user_locale(user, fn -> + deliver( + user.email, + gettext("Update email instructions"), + gettext( + "Hi %{email},\n\nYou can change your email by visiting the URL below:\n\n%{url}\n\nIf you didn't request this change, please ignore this.", + email: user.email, + url: url + ) + ) + end) end @doc """ @@ -51,36 +50,38 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do end defp deliver_magic_link_instructions(user, url) do - deliver(user.email, "Log in instructions", """ - - ============================== - - Hi #{user.email}, - - You can log into your account by visiting the URL below: - - #{url} - - If you didn't request this email, please ignore this. - - ============================== - """) + with_user_locale(user, fn -> + deliver( + user.email, + gettext("Log in instructions"), + gettext( + "Hi %{email},\n\nYou can log into your account by visiting the URL below:\n\n%{url}\n\nIf you didn't request this email, please ignore this.", + email: user.email, + url: url + ) + ) + end) end defp deliver_confirmation_instructions(user, url) do - deliver(user.email, "Confirmation instructions", """ + with_user_locale(user, fn -> + deliver( + user.email, + gettext("Confirmation instructions"), + gettext( + "Hi %{email},\n\nYou can confirm your account by visiting the URL below:\n\n%{url}\n\nIf you didn't create an account with us, please ignore this.", + email: user.email, + url: url + ) + ) + end) + end - ============================== + defp with_user_locale(%User{locale: locale}, fun) when locale in ~w(en uk ru) do + Gettext.with_locale(WhoNeedHelpWeb.Gettext, locale, fun) + end - Hi #{user.email}, - - You can confirm your account by visiting the URL below: - - #{url} - - If you didn't create an account with us, please ignore this. - - ============================== - """) + defp with_user_locale(_user, fun) do + Gettext.with_locale(WhoNeedHelpWeb.Gettext, "en", fun) end end diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex index 57ad285..d5912e0 100644 --- a/lib/who_need_help/catalog.ex +++ b/lib/who_need_help/catalog.ex @@ -159,6 +159,7 @@ defmodule WhoNeedHelp.Catalog do category_attrs = category_attrs |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> normalize_descriptions() |> Map.put_new("parent_id", proposal.parent_id) |> Map.put_new("mode", to_string(proposal.mode)) @@ -271,6 +272,16 @@ defmodule WhoNeedHelp.Catalog do defp normalize_structured_value(%{"type" => "boolean"}, "false"), do: false defp normalize_structured_value(_field, value), do: value + defp normalize_descriptions(%{"descriptions" => descriptions} = attrs) + when is_map(descriptions), + do: attrs + + defp normalize_descriptions(%{"description" => description} = attrs) + when is_binary(description) and description != "", + do: Map.put(attrs, "descriptions", %{"en" => description}) + + defp normalize_descriptions(attrs), do: attrs + defp seed_category(attrs) do case Repo.get_by(Category, slug: attrs.slug) do nil -> @@ -281,9 +292,9 @@ defmodule WhoNeedHelp.Catalog do %{} |> put_backfill(:names, category.names in [nil, %{}], attrs.names) |> put_backfill( - :description, - category.description in [nil, ""], - attrs.description + :descriptions, + category.descriptions in [nil, %{}], + attrs.descriptions ) |> put_backfill( :structured_fields, @@ -328,7 +339,12 @@ defmodule WhoNeedHelp.Catalog do %{ slug: "urgent-delivery", names: names("Urgent delivery", "Термінова доставка", "Срочная доставка"), - description: "Time-sensitive delivery of an item that is already legally arranged.", + descriptions: + names( + "Time-sensitive delivery of an item that is already legally arranged.", + "Термінова доставка речі, отримання якої вже законно узгоджено.", + "Срочная доставка вещи, получение которой уже законно согласовано." + ), active: false, sort_order: 0, structured_fields: %{} @@ -337,8 +353,12 @@ defmodule WhoNeedHelp.Catalog do %{ slug: "medicine-pickup", names: names("Medicine pickup", "Доставка ліків", "Доставка лекарств"), - description: - "Pickup and deliver a legal medicine that is already purchased or reserved.", + descriptions: + names( + "Pickup and deliver a legal medicine that is already purchased or reserved.", + "Отримати й доставити дозволені ліки, які вже придбані або зарезервовані.", + "Получить и доставить разрешённое лекарство, которое уже куплено или зарезервировано." + ), sort_order: 10, structured_fields: %{ "fields" => [ @@ -361,8 +381,12 @@ defmodule WhoNeedHelp.Catalog do %{ slug: "roadside-help", names: names("Roadside help", "Допомога в дорозі", "Помощь в дороге"), - description: - "Non-emergency roadside assistance after people and vehicles are away from active danger.", + descriptions: + names( + "Non-emergency roadside assistance after people and vehicles are away from active danger.", + "Неекстрена допомога в дорозі після усунення безпосередньої небезпеки для людей і транспорту.", + "Неэкстренная помощь в дороге после устранения непосредственной опасности для людей и транспорта." + ), active: false, sort_order: 100, structured_fields: %{} @@ -371,7 +395,12 @@ defmodule WhoNeedHelp.Catalog do %{ slug: "fuel-delivery", names: names("Out of fuel", "Закінчилося пальне", "Закончилось топливо"), - description: "Bring a small lawful quantity of fuel to a vehicle in a secured location.", + descriptions: + names( + "Bring a small lawful quantity of fuel to a vehicle in a secured location.", + "Привезти невелику дозволену кількість пального до транспорту в безпечному місці.", + "Привезти небольшое разрешённое количество топлива к транспорту в безопасном месте." + ), sort_order: 110, structured_fields: %{ "fields" => [ @@ -400,8 +429,12 @@ defmodule WhoNeedHelp.Catalog do %{ slug: "car-wheel-help", names: names("Car wheel help", "Допомога з колесом авто", "Помощь с колесом авто"), - description: - "Help with a flat tyre, inflation, or fitting an available spare in a secured location.", + descriptions: + names( + "Help with a flat tyre, inflation, or fitting an available spare in a secured location.", + "Допомога з проколом, підкачуванням або встановленням наявного запасного колеса в безпечному місці.", + "Помощь с проколом, подкачкой или установкой имеющегося запасного колеса в безопасном месте." + ), sort_order: 120, structured_fields: %{ "fields" => [ @@ -445,7 +478,12 @@ defmodule WhoNeedHelp.Catalog do "Допомога з велосипедом", "Помощь с велосипедом" ), - description: "Help with a bicycle puncture, chain, or brake problem.", + descriptions: + names( + "Help with a bicycle puncture, chain, or brake problem.", + "Допомога з проколом, ланцюгом або гальмами велосипеда.", + "Помощь с проколом, цепью или тормозами велосипеда." + ), sort_order: 130, structured_fields: %{ "fields" => [ @@ -486,8 +524,12 @@ defmodule WhoNeedHelp.Catalog do "Допомога з мотоциклом", "Помощь с мотоциклом" ), - description: - "Help with a motorcycle chain, tyre, or battery after the vehicle is secured.", + descriptions: + names( + "Help with a motorcycle chain, tyre, or battery after the vehicle is secured.", + "Допомога з ланцюгом, колесом або акумулятором мотоцикла після його переміщення в безпечне місце.", + "Помощь с цепью, колесом или аккумулятором мотоцикла после его перемещения в безопасное место." + ), sort_order: 140, structured_fields: %{ "fields" => [ @@ -521,8 +563,12 @@ defmodule WhoNeedHelp.Catalog do "Поломка транспортного засобу", "Поломка транспортного средства" ), - description: - "Non-emergency practical help with a stopped vehicle in a secured location.", + descriptions: + names( + "Non-emergency practical help with a stopped vehicle in a secured location.", + "Неекстрена практична допомога із зупиненим транспортом у безпечному місці.", + "Неэкстренная практическая помощь с остановившимся транспортом в безопасном месте." + ), sort_order: 150, structured_fields: %{ "fields" => [ @@ -573,8 +619,12 @@ defmodule WhoNeedHelp.Catalog do "Допомога після дорожньої події", "Помощь после дорожного происшествия" ), - description: - "Non-emergency presence or practical support only after immediate danger is handled.", + descriptions: + names( + "Non-emergency presence or practical support only after immediate danger is handled.", + "Неекстрена присутність або практична підтримка лише після усунення безпосередньої небезпеки.", + "Неэкстренное присутствие или практическая поддержка только после устранения непосредственной опасности." + ), sort_order: 160, structured_fields: %{ "fields" => [ @@ -630,8 +680,12 @@ defmodule WhoNeedHelp.Catalog do %{ slug: "social-activity", names: names("Social activity", "Спільне дозвілля", "Совместный досуг"), - description: - "Optional social plans with organizer-approved participants and no effect on helper reputation.", + descriptions: + names( + "Optional social plans with organizer-approved participants and no effect on helper reputation.", + "Необов’язкові спільні плани з учасниками, яких схвалює організатор, без впливу на репутацію помічника.", + "Необязательные совместные планы с участниками, которых одобряет организатор, без влияния на репутацию помощника." + ), mode: :activity, active: false, sort_order: 1_000, @@ -641,7 +695,12 @@ defmodule WhoNeedHelp.Catalog do %{ slug: "coffee-meetup", names: names("Coffee or tea", "Кава або чай", "Кофе или чай"), - description: "Meet in a public place for coffee, tea, or conversation.", + descriptions: + names( + "Meet in a public place for coffee, tea, or conversation.", + "Зустрітися в публічному місці за кавою, чаєм або розмовою.", + "Встретиться в публичном месте за кофе, чаем или разговором." + ), mode: :activity, sort_order: 1_010, structured_fields: %{ @@ -669,7 +728,12 @@ defmodule WhoNeedHelp.Catalog do %{ slug: "cinema-meetup", names: names("Cinema", "Кіно", "Кино"), - description: "Find company for a public cinema screening.", + descriptions: + names( + "Find company for a public cinema screening.", + "Знайти компанію для перегляду фільму в кінотеатрі.", + "Найти компанию для просмотра фильма в кинотеатре." + ), mode: :activity, sort_order: 1_020, structured_fields: %{ @@ -687,7 +751,12 @@ defmodule WhoNeedHelp.Catalog do %{ slug: "city-walk", names: names("Walk", "Прогулянка", "Прогулка"), - description: "Arrange a walk in a public area.", + descriptions: + names( + "Arrange a walk in a public area.", + "Домовитися про прогулянку в публічному місці.", + "Договориться о прогулке в публичном месте." + ), mode: :activity, sort_order: 1_030, structured_fields: %{ @@ -704,7 +773,12 @@ defmodule WhoNeedHelp.Catalog do %{ slug: "hiking-group", names: names("Hiking", "Похід", "Поход"), - description: "Plan a group hike with explicit route difficulty and safety preparation.", + descriptions: + names( + "Plan a group hike with explicit route difficulty and safety preparation.", + "Спланувати груповий похід із зазначенням складності маршруту та підготовкою з безпеки.", + "Спланировать групповой поход с указанием сложности маршрута и подготовкой по безопасности." + ), mode: :activity, sort_order: 1_040, structured_fields: %{ diff --git a/lib/who_need_help/catalog/category.ex b/lib/who_need_help/catalog/category.ex index 152f9ff..ea81f0c 100644 --- a/lib/who_need_help/catalog/category.ex +++ b/lib/who_need_help/catalog/category.ex @@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Catalog.Category do field :slug, :string field :names, :map, default: %{} field :description, :string + field :descriptions, :map, default: %{} field :mode, Ecto.Enum, values: [:help, :activity], default: :help field :active, :boolean, default: true field :sort_order, :integer, default: 0 @@ -25,6 +26,7 @@ defmodule WhoNeedHelp.Catalog.Category do :slug, :names, :description, + :descriptions, :mode, :active, :sort_order, @@ -35,6 +37,7 @@ defmodule WhoNeedHelp.Catalog.Category do |> validate_format(:slug, ~r/^[a-z0-9-]+$/) |> validate_length(:slug, max: 80) |> validate_names() + |> validate_descriptions() |> validate_structured_fields() |> unique_constraint(:slug) end @@ -43,6 +46,13 @@ defmodule WhoNeedHelp.Catalog.Category do names[to_string(locale)] || names["en"] || names |> Map.values() |> List.first() || "Help" end + def description(%__MODULE__{descriptions: descriptions, description: legacy}, locale) do + descriptions[to_string(locale)] || + descriptions["en"] || + descriptions |> Map.values() |> List.first() || + legacy + end + defp validate_names(changeset) do validate_change(changeset, :names, fn :names, names -> if is_map(names) and map_size(names) > 0 and @@ -56,6 +66,16 @@ defmodule WhoNeedHelp.Catalog.Category do end) end + defp validate_descriptions(changeset) do + validate_change(changeset, :descriptions, fn :descriptions, descriptions -> + if is_map(descriptions) and Enum.all?(descriptions, &valid_label?/1) do + [] + else + [descriptions: "must contain non-empty localized descriptions"] + end + end) + end + defp validate_structured_fields(changeset) do validate_change(changeset, :structured_fields, fn :structured_fields, schema -> case schema do diff --git a/lib/who_need_help_web/controllers/feedback_html/show.html.heex b/lib/who_need_help_web/controllers/feedback_html/show.html.heex index 1d79a3f..1ed2532 100644 --- a/lib/who_need_help_web/controllers/feedback_html/show.html.heex +++ b/lib/who_need_help_web/controllers/feedback_html/show.html.heex @@ -1,28 +1,35 @@
OPENAI BUILD WEEK
-

Build feedback

+

{gettext("Build feedback")}

- Who Need Help was developed with the local Codex CLI authenticated through the - builder's ChatGPT subscription. No OpenAI API key or usage-based API fallback is used. + {gettext( + "Who Need Help was developed with the local Codex CLI authenticated through the builder's ChatGPT subscription. No OpenAI API key or usage-based API fallback is used." + )}

- Main Codex session ID + {gettext("Main Codex session ID")}
{@session_id}

- 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." + )}

-

What Codex contributed

+

{gettext("What Codex contributed")}

- 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." + )}

-

Human decisions retained

+

{gettext("Human decisions retained")}

- 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." + )}

diff --git a/lib/who_need_help_web/controllers/page_html/home.html.heex b/lib/who_need_help_web/controllers/page_html/home.html.heex index c75623f..4a274aa 100644 --- a/lib/who_need_help_web/controllers/page_html/home.html.heex +++ b/lib/who_need_help_web/controllers/page_html/home.html.heex @@ -8,8 +8,9 @@ {gettext("Help can be closer than you think.")}

- Who Need Help connects people who urgently need medicine pickup or safe - roadside assistance with nearby volunteers who can help for free. + {gettext( + "Who Need Help connects people who urgently need medicine pickup or safe roadside assistance with nearby volunteers who can help for free." + )}

<.link navigate={~p"/requests"} class="btn btn-primary btn-lg"> @@ -26,7 +27,9 @@ <% end %>

- 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." + )}

@@ -34,19 +37,23 @@
- Medicine pickup - urgent + {gettext("Medicine pickup")} + {gettext("urgent")}
-

Medicine order is ready at the pharmacy

+

{gettext("Medicine order is ready at the pharmacy")}

- “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." + )}

-
Approximate area
-
Exact address after matching
+
{gettext("Approximate area")}
+
+ {gettext("Exact address after matching")} +
- +
@@ -55,23 +62,23 @@
1
-

Post a clear request

+

{gettext("Post a clear request")}

- Choose a category, urgency, and safe location visibility. + {gettext("Choose a category, urgency, and safe location visibility.")}

2
-

Match and coordinate

+

{gettext("Match and coordinate")}

- Use private chat and optional live location sharing. + {gettext("Use private chat and optional live location sharing.")}

3
-

Verify the handover

+

{gettext("Verify the handover")}

- Both confirm and the helper enters a one-time code. + {gettext("Both confirm and the helper enters a one-time code.")}

diff --git a/lib/who_need_help_web/controllers/page_html/safety.html.heex b/lib/who_need_help_web/controllers/page_html/safety.html.heex index e1f069b..7ada2be 100644 --- a/lib/who_need_help_web/controllers/page_html/safety.html.heex +++ b/lib/who_need_help_web/controllers/page_html/safety.html.heex @@ -1,75 +1,76 @@
-
READ BEFORE USING THE SERVICE
-

Safety rules

+
+ {gettext("READ BEFORE USING THE SERVICE")} +
+

{gettext("Safety rules")}

- Who Need Help coordinates voluntary help between adults. It cannot verify every person, - request, item, route, or outcome and cannot guarantee safety. + {gettext( + "Who Need Help coordinates voluntary help between adults. It cannot verify every person, request, item, route, or outcome and cannot guarantee safety." + )}

-

Not for emergencies

+

{gettext("Not for emergencies")}

- Do not wait for a volunteer when there is immediate danger, a serious medical situation, - fire, violence, or a crime in progress. Contact the emergency service for your location. + {gettext( + "Do not wait for a volunteer when there is immediate danger, a serious medical situation, fire, violence, or a crime in progress. Contact the emergency service for your location." + )}

-

Medicine requests

+

{gettext("Medicine requests")}

- Only request pickup of a legal item already reserved or paid for. Do not request medical - advice, a prescription, controlled substances, cash advances, repackaging, or a purchase - on your behalf. Follow pharmacy rules and local law. + {gettext( + "Only request pickup of a legal item already reserved or paid for. Do not request medical advice, a prescription, controlled substances, cash advances, repackaging, or a purchase on your behalf. Follow pharmacy rules and local law." + )}

-

Social activities

+

{gettext("Social activities")}

- Activities are optional plans, not urgent help, and do not affect helper reputation. - Meet in a suitable public place first. Organizers approve every participant; exact - coordinates and group chat are limited to approved participants. Share route and safety - plans for hikes, and never publish home addresses, access codes, or travel documents. + {gettext( + "Activities are optional plans, not urgent help, and do not affect helper reputation. Meet in a suitable public place first. Organizers approve every participant; exact coordinates and group chat are limited to approved participants. Share route and safety plans for hikes, and never publish home addresses, access codes, or travel documents." + )}

-

Free help and thanks

+

{gettext("Free help and thanks")}

- Help is voluntary and has no required fee. An external thank-you link is optional and - appears only after completion. The platform does not receive, split, refund, guarantee, - or report that transfer. + {gettext( + "Help is voluntary and has no required fee. An external thank-you link is optional and appears only after completion. The platform does not receive, split, refund, guarantee, or report that transfer." + )}

-

Location privacy

+

{gettext("Location privacy")}

- Public location can be hidden, approximate, or explicitly exact. Live tracking is - optional. A browser shares only while its page is open; Android shows a persistent - foreground-service notification while sharing. The current exact point is deleted when - sharing stops, a match ends, or either participant blocks the other; derived movement and - proximity evidence may remain. + {gettext( + "Public location can be hidden, approximate, or explicitly exact. Live tracking is optional. A browser shares only while its page is open; Android shows a persistent foreground-service notification while sharing. The current exact point is deleted when sharing stops, a match ends, or either participant blocks the other; derived movement and proximity evidence may remain." + )}

-

Identity and reputation

+

{gettext("Identity and reputation")}

- Email confirmation and social links are not identity guarantees. Manually attached social - links are marked unverified. Check the person's history, unique interactions, handover - evidence, and revealed reviews, and meet only where you feel safe. + {gettext( + "Email confirmation and social links are not identity guarantees. Manually attached social links are marked unverified. Check the person's history, unique interactions, handover evidence, and revealed reviews, and meet only where you feel safe." + )}

-

Block and report

+

{gettext("Block and report")}

- Block a person to hide discovery in both directions and stop new chat. Report dangerous, - fraudulent, prohibited, impersonating, harassing, or spam content. Automated signals ask - for human review and are not proof by themselves. + {gettext( + "Block a person to hide discovery in both directions and stop new chat. Report dangerous, fraudulent, prohibited, impersonating, harassing, or spam content. Automated signals ask for human review and are not proof by themselves." + )}

diff --git a/lib/who_need_help_web/controllers/social_oauth_controller.ex b/lib/who_need_help_web/controllers/social_oauth_controller.ex index 1831767..5a36be3 100644 --- a/lib/who_need_help_web/controllers/social_oauth_controller.ex +++ b/lib/who_need_help_web/controllers/social_oauth_controller.ex @@ -21,14 +21,14 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do |> redirect(external: url) else false -> - oauth_error(conn, "That verification provider is not configured.") + oauth_error(conn, gettext("That verification provider is not configured.")) {:error, :unsupported_provider} -> - oauth_error(conn, "That verification provider is not supported.") + oauth_error(conn, gettext("That verification provider is not supported.")) {:error, error} -> Logger.warning("Unable to start social OAuth (#{error_name(error)})") - oauth_error(conn, "Social verification could not be started.") + oauth_error(conn, gettext("Social verification could not be started.")) end end @@ -48,21 +48,34 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do conn |> delete_session(session_key(provider)) - |> put_flash(:info, "GitHub profile verified.") + |> put_flash(:info, gettext("GitHub profile verified.")) |> redirect(to: ~p"/profile") else nil -> - callback_error(conn, provider_param, "Social verification session expired. Please retry.") + callback_error( + conn, + provider_param, + gettext("Social verification session expired. Please retry.") + ) false -> - callback_error(conn, provider_param, "Social verification session is invalid.") + callback_error( + conn, + provider_param, + gettext("Social verification session is invalid.") + ) {:error, :already_linked} -> - callback_error(conn, provider_param, "That social account is already linked.") + callback_error(conn, provider_param, gettext("That social account is already linked.")) {:error, error} -> Logger.warning("Social OAuth callback failed (#{error_name(error)})") - callback_error(conn, provider_param, "Social verification failed. Please retry.") + + callback_error( + conn, + provider_param, + gettext("Social verification failed. Please retry.") + ) end end diff --git a/lib/who_need_help_web/controllers/user_registration_controller.ex b/lib/who_need_help_web/controllers/user_registration_controller.ex index 125f36f..1be7121 100644 --- a/lib/who_need_help_web/controllers/user_registration_controller.ex +++ b/lib/who_need_help_web/controllers/user_registration_controller.ex @@ -26,7 +26,10 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do conn |> put_flash( :info, - "An email was sent to #{user.email}, please access it to confirm your account." + gettext( + "An email was sent to %{email}, please access it to confirm your account.", + email: user.email + ) ) |> redirect(to: ~p"/users/log-in") @@ -37,7 +40,10 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do {:error, :rate_limited} -> conn |> put_status(:too_many_requests) - |> put_flash(:error, "Too many registration attempts in the configured time window.") + |> put_flash( + :error, + gettext("Too many registration attempts in the configured time window.") + ) |> render(:new, changeset: Accounts.change_user_registration(%User{}, user_params) ) diff --git a/lib/who_need_help_web/controllers/user_registration_html/new.html.heex b/lib/who_need_help_web/controllers/user_registration_html/new.html.heex index d24b6ad..ad19b0e 100644 --- a/lib/who_need_help_web/controllers/user_registration_html/new.html.heex +++ b/lib/who_need_help_web/controllers/user_registration_html/new.html.heex @@ -2,22 +2,27 @@
<.header> - Register for an account + {gettext("Register for an account")} <:subtitle> - Already registered? + {gettext("Already registered?")} <.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline"> - Log in + {gettext("Log in")} - to your account now. + {gettext("to your account now.")}
<.form :let={f} for={@changeset} action={~p"/users/register"}> + <.input field={f[:email]} type="email" - label="Email" + label={gettext("Email")} autocomplete="username" spellcheck="false" required @@ -27,30 +32,32 @@ <.input field={f[:display_name]} type="text" - label="Display name" + label={gettext("Display name")} autocomplete="name" required />

- Read the <.link href={~p"/safety"} class="link font-semibold">safety rules - before confirming. + {gettext("Read the")} + <.link href={~p"/safety"} class="link font-semibold">{gettext("safety rules")} + {gettext("before confirming.")}

<.input field={f[:terms_accepted]} type="checkbox" - label="I am 18 or older and accept the safety rules" + label={gettext("I am 18 or older and accept the safety rules")} required />

- Who Need Help is not an emergency or medical service. Medicine requests are limited - to pickup of legal items already purchased or reserved. + {gettext( + "Who Need Help is not an emergency or medical service. Medicine requests are limited to pickup of legal items already purchased or reserved." + )}

- <.button phx-disable-with="Creating account..." class="btn btn-primary w-full"> - Create an account + <.button phx-disable-with={gettext("Creating account...")} class="btn btn-primary w-full"> + {gettext("Create an account")}
diff --git a/lib/who_need_help_web/controllers/user_session_controller.ex b/lib/who_need_help_web/controllers/user_session_controller.ex index a57a38f..0a0b53b 100644 --- a/lib/who_need_help_web/controllers/user_session_controller.ex +++ b/lib/who_need_help_web/controllers/user_session_controller.ex @@ -16,8 +16,8 @@ defmodule WhoNeedHelpWeb.UserSessionController do def create(conn, %{"user" => %{"token" => token} = user_params} = params) do info = case params do - %{"_action" => "confirmed"} -> "User confirmed successfully." - _ -> "Welcome back!" + %{"_action" => "confirmed"} -> gettext("User confirmed successfully.") + _ -> gettext("Welcome back!") end case Accounts.login_user_by_magic_link(token) do @@ -28,7 +28,7 @@ defmodule WhoNeedHelpWeb.UserSessionController do {:error, :not_found} -> conn - |> put_flash(:error, "The link is invalid or it has expired.") + |> put_flash(:error, gettext("The link is invalid or it has expired.")) |> render(:new, form: Phoenix.Component.to_form(%{}, as: "user")) end end @@ -37,14 +37,14 @@ defmodule WhoNeedHelpWeb.UserSessionController do def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do if user = Accounts.get_user_by_email_and_password(email, password) do conn - |> put_flash(:info, "Welcome back!") + |> put_flash(:info, gettext("Welcome back!")) |> UserAuth.log_in_user(user, user_params) else form = Phoenix.Component.to_form(user_params, as: "user") # In order to prevent user enumeration attacks, don't disclose whether the email is registered. conn - |> put_flash(:error, "Invalid email or password") + |> put_flash(:error, gettext("Invalid email or password")) |> render(:new, form: form) end end @@ -63,7 +63,9 @@ defmodule WhoNeedHelpWeb.UserSessionController do end info = - "If your email is in our system, you will receive instructions for logging in shortly." + gettext( + "If your email is in our system, you will receive instructions for logging in shortly." + ) conn |> put_flash(:info, info) @@ -72,7 +74,10 @@ defmodule WhoNeedHelpWeb.UserSessionController do {:error, :rate_limited} -> conn |> put_status(:too_many_requests) - |> put_flash(:error, "Too many sign-in emails in the configured time window.") + |> put_flash( + :error, + gettext("Too many sign-in emails in the configured time window.") + ) |> render(:new, form: Phoenix.Component.to_form(%{"email" => email}, as: "user")) end end @@ -87,14 +92,14 @@ defmodule WhoNeedHelpWeb.UserSessionController do |> render(:confirm) else conn - |> put_flash(:error, "Magic link is invalid or it has expired.") + |> put_flash(:error, gettext("Magic link is invalid or it has expired.")) |> redirect(to: ~p"/users/log-in") end end def delete(conn, _params) do conn - |> put_flash(:info, "Logged out successfully.") + |> put_flash(:info, gettext("Logged out successfully.")) |> UserAuth.log_out_user() end end diff --git a/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex b/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex index 7118b86..108b15f 100644 --- a/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex +++ b/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex @@ -1,7 +1,7 @@
- <.header>Welcome {@user.email} + <.header>{gettext("Welcome %{email}", email: @user.email)}
<.form @@ -15,13 +15,16 @@ <.button name={@form[:remember_me].name} value="true" - phx-disable-with="Confirming..." + phx-disable-with={gettext("Confirming...")} class="btn btn-primary w-full" > - Confirm and stay logged in + {gettext("Confirm and stay logged in")} - <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2"> - Confirm and log in only this time + <.button + phx-disable-with={gettext("Confirming...")} + class="btn btn-primary btn-soft w-full mt-2" + > + {gettext("Confirm and log in only this time")} @@ -34,26 +37,33 @@ > <%= if @current_scope do %> - <.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full"> - Log in + <.button + variant="primary" + phx-disable-with={gettext("Logging in...")} + class="btn btn-primary w-full" + > + {gettext("Log in")} <% else %> <.button name={@form[:remember_me].name} value="true" - phx-disable-with="Logging in..." + phx-disable-with={gettext("Logging in...")} class="btn btn-primary w-full" > - Keep me logged in on this device + {gettext("Keep me logged in on this device")} - <.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2"> - Log me in only this time + <.button + phx-disable-with={gettext("Logging in...")} + class="btn btn-primary btn-soft w-full mt-2" + > + {gettext("Log me in only this time")} <% end %>

- 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.")}

diff --git a/lib/who_need_help_web/controllers/user_session_html/new.html.heex b/lib/who_need_help_web/controllers/user_session_html/new.html.heex index 2df8002..b14c348 100644 --- a/lib/who_need_help_web/controllers/user_session_html/new.html.heex +++ b/lib/who_need_help_web/controllers/user_session_html/new.html.heex @@ -2,16 +2,18 @@
<.header> -

Log in

+

{gettext("Log in")}

<:subtitle> <%= if @current_scope do %> - You need to reauthenticate to perform sensitive actions on your account. + {gettext("You need to reauthenticate to perform sensitive actions on your account.")} <% else %> - Don't have an account? <.link + {gettext("Don't have an account?")} + <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline" phx-no-format - >Sign up for an account now. + >{gettext("Sign up")} + {gettext("for an account now.")} <% end %> @@ -20,9 +22,10 @@
<.icon name="hero-information-circle" class="size-6 shrink-0" />
-

You are running the local mail adapter.

+

{gettext("You are running the local mail adapter.")}

- To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. + {gettext("To see sent emails, visit")} + <.link href="/dev/mailbox" class="underline">{gettext("the mailbox page")}.

@@ -32,25 +35,25 @@ readonly={!!@current_scope} field={f[:email]} type="email" - label="Email" + label={gettext("Email")} autocomplete="username" spellcheck="false" required phx-mounted={JS.focus()} /> <.button class="btn btn-primary w-full"> - Log in with email + {gettext("Log in with email")} -
or
+
{gettext("or")}
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}> <.input readonly={!!@current_scope} field={f[:email]} type="email" - label="Email" + label={gettext("Email")} autocomplete="username" spellcheck="false" required @@ -58,15 +61,15 @@ <.input field={f[:password]} type="password" - label="Password" + label={gettext("Password")} autocomplete="current-password" spellcheck="false" /> <.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true"> - Log in and stay logged in + {gettext("Log in and stay logged in")} <.button class="btn btn-primary btn-soft w-full mt-2"> - Log in only this time + {gettext("Log in only this time")}
diff --git a/lib/who_need_help_web/controllers/user_settings_controller.ex b/lib/who_need_help_web/controllers/user_settings_controller.ex index 92ca246..d112cd2 100644 --- a/lib/who_need_help_web/controllers/user_settings_controller.ex +++ b/lib/who_need_help_web/controllers/user_settings_controller.ex @@ -28,7 +28,7 @@ defmodule WhoNeedHelpWeb.UserSettingsController do conn |> put_flash( :info, - "A link to confirm your email change has been sent to the new address." + gettext("A link to confirm your email change has been sent to the new address.") ) |> redirect(to: ~p"/users/settings") @@ -44,7 +44,7 @@ defmodule WhoNeedHelpWeb.UserSettingsController do case Accounts.update_user_password(user, user_params) do {:ok, {user, _}} -> conn - |> put_flash(:info, "Password updated successfully.") + |> put_flash(:info, gettext("Password updated successfully.")) |> put_session(:user_return_to, ~p"/users/settings") |> UserAuth.log_in_user(user) @@ -57,12 +57,12 @@ defmodule WhoNeedHelpWeb.UserSettingsController do case Accounts.update_user_email(conn.assigns.current_scope.user, token) do {:ok, _user} -> conn - |> put_flash(:info, "Email changed successfully.") + |> put_flash(:info, gettext("Email changed successfully.")) |> redirect(to: ~p"/users/settings") {:error, _} -> conn - |> put_flash(:error, "Email change link is invalid or it has expired.") + |> put_flash(:error, gettext("Email change link is invalid or it has expired.")) |> redirect(to: ~p"/users/settings") end end diff --git a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex index 6306e80..7193e9f 100644 --- a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex +++ b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex @@ -1,8 +1,8 @@
<.header> - Account Settings - <:subtitle>Manage your account email address and password settings + {gettext("Account Settings")} + <:subtitle>{gettext("Manage your account email address and password settings")}
@@ -12,13 +12,15 @@ <.input field={f[:email]} type="email" - label="Email" + label={gettext("Email")} autocomplete="username" spellcheck="false" required /> - <.button variant="primary" phx-disable-with="Changing...">Change Email + <.button variant="primary" phx-disable-with={gettext("Changing...")}> + {gettext("Change Email")} +
@@ -29,7 +31,7 @@ <.input field={f[:password]} type="password" - label="New password" + label={gettext("New password")} autocomplete="new-password" spellcheck="false" required @@ -37,13 +39,13 @@ <.input field={f[:password_confirmation]} type="password" - label="Confirm new password" + label={gettext("Confirm new password")} autocomplete="new-password" spellcheck="false" required /> - <.button variant="primary" phx-disable-with="Changing..."> - Save Password + <.button variant="primary" phx-disable-with={gettext("Changing...")}> + {gettext("Save Password")} diff --git a/lib/who_need_help_web/live/activity_live/index.ex b/lib/who_need_help_web/live/activity_live/index.ex index 40e6dfe..ad1a0fb 100644 --- a/lib/who_need_help_web/live/activity_live/index.ex +++ b/lib/who_need_help_web/live/activity_live/index.ex @@ -30,7 +30,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do Activities.list_open_activities(socket.assigns.current_scope, socket.assigns.filters) socket - |> assign(:page_title, "Activities") + |> assign(:page_title, gettext("Activities")) |> assign(:activities, activities) |> assign(:my_activities, Activities.list_my_activities(socket.assigns.current_scope)) |> assign(:categories, Catalog.list_categories(:activity)) @@ -52,25 +52,38 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do end end + defp status_label(:open), do: gettext("Open") + defp status_label(:completed), do: gettext("Completed") + defp status_label(:cancelled), do: gettext("Cancelled") + defp status_label(value), do: value + @impl true def render(assigns) do ~H"""
-
OPTIONAL SOCIAL PLANS
-

Find people to join

+
{gettext("OPTIONAL SOCIAL PLANS")}
+

+ {gettext("Find people to join")} +

- 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." + )}

- <.link navigate={~p"/activities/new"} class="btn btn-info">Create activity + <.link navigate={~p"/activities/new"} class="btn btn-info"> + {gettext("Create activity")} +
<.icon name="hero-shield-check" class="size-5" /> - 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." + )}
@@ -82,12 +95,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do <.input field={@filter_form[:category_id]} type="select" - label="Activity type" + label={gettext("Activity type")} options={[ - {"All activities", ""} + {gettext("All activities"), ""} | Enum.map( @categories, - &{Catalog.category_path(&1, @current_scope.user.locale), &1.id} + &{Catalog.category_path(&1, Gettext.get_locale(WhoNeedHelpWeb.Gettext)), &1.id} ) ]} /> @@ -99,8 +112,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do :if={@activities == []} class="rounded-3xl border border-dashed border-base-300 p-12 text-center" > -

No open activities yet

-

Create a clear plan in a public place.

+

{gettext("No open activities yet")}

+

+ {gettext("Create a clear plan in a public place.")} +

<.link @@ -112,7 +127,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
- {Catalog.category_path(activity.category, @current_scope.user.locale)} + {Catalog.category_path( + activity.category, + Gettext.get_locale(WhoNeedHelpWeb.Gettext) + )} {Enum.count(activity.participants, &(&1.status == :approved))}/{activity.capacity} @@ -125,8 +143,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
📍 {activity.location_label} - 🕒 {Calendar.strftime(activity.starts_at, "%d %b, %H:%M UTC")} - by {activity.creator.display_name || "Community member"} + 🕒 {Calendar.strftime(activity.starts_at, "%d.%m.%Y, %H:%M UTC")} + + {gettext("by %{name}", + name: activity.creator.display_name || gettext("Community member") + )} +
@@ -143,14 +165,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
-

Your activities

+

{gettext("Your activities")}

<.link :for={activity <- @my_activities} navigate={~p"/activities/#{activity.id}"} class="rounded-2xl bg-base-200 p-4" > - {activity.status} + {status_label(activity.status)}
{activity.title}
diff --git a/lib/who_need_help_web/live/activity_live/new.ex b/lib/who_need_help_web/live/activity_live/new.ex index fb1301d..912d7e0 100644 --- a/lib/who_need_help_web/live/activity_live/new.ex +++ b/lib/who_need_help_web/live/activity_live/new.ex @@ -8,7 +8,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do def mount(_params, _session, socket) do {:ok, socket - |> assign(:page_title, "Create activity") + |> assign(:page_title, gettext("Create activity")) |> assign(:categories, Catalog.list_categories(:activity)) |> assign(:selected_category, nil) |> assign(:structured_fields, []) @@ -33,7 +33,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do {:ok, activity} -> {:noreply, socket - |> put_flash(:info, "Activity published. You approve every participant.") + |> put_flash(:info, gettext("Activity published. You approve every participant.")) |> push_navigate(to: ~p"/activities/#{activity.id}")} {:error, %Ecto.Changeset{} = changeset} -> @@ -94,10 +94,13 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do end defp error_message(:account_not_eligible), - do: "Confirm your email and ensure your account is active before publishing." + do: gettext("Confirm your email and ensure your account is active before publishing.") - defp error_message(:rate_limited), do: "Too many actions in the configured time window." - defp error_message(reason), do: "Could not publish the activity: #{inspect(reason)}" + defp error_message(:rate_limited), + do: gettext("Too many actions in the configured time window.") + + defp error_message(reason), + do: gettext("Could not publish the activity: %{reason}", reason: inspect(reason)) @impl true def render(assigns) do @@ -105,17 +108,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4"> - ← Back to activities + {gettext("← Back to activities")} -

Create an activity

+

{gettext("Create an activity")}

- 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.")}

<.icon name="hero-exclamation-triangle" class="size-5" /> - 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." + )}
@@ -129,12 +134,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do <.input field={@form[:category_id]} type="select" - label="Activity type" - prompt="Choose activity" + label={gettext("Activity type")} + prompt={gettext("Choose activity")} options={ Enum.map( @categories, - &{Catalog.category_path(&1, @current_scope.user.locale), &1.id} + &{Catalog.category_path(&1, Gettext.get_locale(WhoNeedHelpWeb.Gettext)), &1.id} ) } /> @@ -143,24 +148,29 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do id="selected-activity-category-description" class="-mt-3 text-sm text-base-content/60" > - {@selected_category.description} + {Catalog.Category.description( + @selected_category, + Gettext.get_locale(WhoNeedHelpWeb.Gettext) + )}

-

Activity details

+

{gettext("Activity details")}

@@ -202,34 +212,49 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do

- <.input field={@form[:title]} label="Short title" placeholder="Coffee and conversation" /> + <.input + field={@form[:title]} + label={gettext("Short title")} + placeholder={gettext("Coffee and conversation")} + /> <.input field={@form[:description]} type="textarea" - label="Plan and expectations" - placeholder="Describe the public plan, approximate duration, and who might enjoy it." + label={gettext("Plan and expectations")} + placeholder={ + gettext("Describe the public plan, approximate duration, and who might enjoy it.") + } />
- <.input field={@form[:starts_at]} type="datetime-local" label="Starts at (UTC)" /> + <.input + field={@form[:starts_at]} + type="datetime-local" + label={gettext("Starts at (UTC)")} + /> <.input field={@form[:join_deadline]} type="datetime-local" - label="Join requests close (UTC)" + label={gettext("Join requests close (UTC)")} />
- <.input field={@form[:capacity]} type="number" min="2" label="Total group capacity" /> + <.input + field={@form[:capacity]} + type="number" + min="2" + label={gettext("Total group capacity")} + /> <.input field={@form[:location_label]} - label="Approximate public meeting area" - placeholder="City centre, near the main entrance" + label={gettext("Approximate public meeting area")} + placeholder={gettext("City centre, near the main entrance")} /> <.input field={@form[:location_visibility]} type="select" - label="Public map visibility" + label={gettext("Public map visibility")} options={[ - {"Approximate area", "approximate_public"}, - {"Hidden", "hidden"} + {gettext("Approximate area"), "approximate_public"}, + {gettext("Hidden"), "hidden"} ]} />
@@ -239,10 +264,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do id="activity-location-picker" class="btn btn-outline btn-sm" > - Use my current foreground location + {gettext("Use my current foreground location")}

- Exact coordinates are shown only to participants you approve. + {gettext("Exact coordinates are shown only to participants you approve.")}

<.input @@ -250,25 +275,30 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do id="activity-latitude" type="number" step="any" - label="Latitude" + label={gettext("Latitude")} /> <.input field={@form[:longitude]} id="activity-longitude" type="number" step="any" - label="Longitude" + label={gettext("Longitude")} />
- <.button class="btn btn-info btn-lg w-full" phx-disable-with="Publishing…"> - Publish activity + <.button + class="btn btn-info btn-lg w-full" + phx-disable-with={gettext("Publishing…")} + > + {gettext("Publish activity")}
diff --git a/lib/who_need_help_web/live/activity_live/show.ex b/lib/who_need_help_web/live/activity_live/show.ex index 7406187..49e6780 100644 --- a/lib/who_need_help_web/live/activity_live/show.ex +++ b/lib/who_need_help_web/live/activity_live/show.ex @@ -13,7 +13,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do {:error, _reason} -> {:ok, socket - |> put_flash(:error, "Activity is unavailable.") + |> put_flash(:error, gettext("Activity is unavailable.")) |> push_navigate(to: ~p"/activities")} end end @@ -96,7 +96,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do socket |> assign(:report_form, report_form()) |> assign(:report_message_id, nil) - |> put_flash(:info, "Report sent to moderators.")} + |> put_flash(:info, gettext("Report sent to moderators."))} {:error, reason} -> {:noreply, put_flash(socket, :error, error_message(reason))} @@ -112,7 +112,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do if message do {:noreply, assign(socket, :report_message_id, message.id)} else - {:noreply, put_flash(socket, :error, "That message cannot be reported from this view.")} + {:noreply, + put_flash(socket, :error, gettext("That message cannot be reported from this view."))} end end @@ -125,7 +126,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do {:ok, _block} -> {:noreply, socket - |> put_flash(:info, "Organizer blocked. Their activities and messages are hidden.") + |> put_flash( + :info, + gettext("Organizer blocked. Their activities and messages are hidden.") + ) |> push_navigate(to: ~p"/activities")} {:error, reason} -> @@ -146,7 +150,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do {:error, _} -> socket - |> put_flash(:error, "Activity is unavailable.") + |> put_flash(:error, gettext("Activity is unavailable.")) |> push_navigate(to: ~p"/activities") end end @@ -180,6 +184,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do |> assign(:organizer?, organizer?) |> assign(:approved?, approved?) |> assign(:coordinates, coordinates) + |> assign( + :structured_details, + structured_details(activity, Gettext.get_locale(WhoNeedHelpWeb.Gettext)) + ) |> assign(:markers, Jason.encode!(markers)) |> assign(:message_form, to_form(%{"body" => ""}, as: :activity_message)) |> assign(:report_message_id, nil) @@ -202,28 +210,87 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do defp ordered_messages(activity), do: Enum.sort_by(activity.messages, & &1.inserted_at, DateTime) + defp structured_details(activity, locale) do + activity.category + |> WhoNeedHelp.Catalog.structured_fields() + |> Enum.flat_map(fn field -> + case activity.structured_data[field["key"]] do + nil -> + [] + + value -> + label = localized(field["label"], locale) || field["key"] + + [{label, structured_value(field, value, locale)}] + end + end) + end + + defp structured_value(%{"type" => "select"} = field, value, locale) do + option = + Enum.find(field["options"] || [], fn + %{"value" => candidate} -> to_string(candidate) == to_string(value) + candidate -> to_string(candidate) == to_string(value) + end) + + case option do + %{"label" => labels} -> localized(labels, locale) || to_string(value) + nil -> to_string(value) + candidate -> to_string(candidate) + end + end + + defp structured_value(%{"type" => "boolean"}, value, _locale) + when value in [true, "true", "1"], + do: gettext("Yes") + + defp structured_value(%{"type" => "boolean"}, _value, _locale), do: gettext("No") + defp structured_value(_field, value, _locale), do: to_string(value) + + defp localized(labels, locale) when is_map(labels), + do: labels[to_string(locale)] || labels["en"] + + defp localized(_labels, _locale), do: nil + defp report_form do to_form(%{"reason" => "harassment", "details" => ""}, as: :report) end - defp error_message(:already_joined), do: "You already have a join request for this activity." - defp error_message(:capacity_reached), do: "The approved group has reached its capacity." - defp error_message(:join_closed), do: "Join requests are closed." - defp error_message(:blocked), do: "This action is unavailable." - defp error_message(:cannot_report_self), do: "You cannot report your own content." - defp error_message(:forbidden), do: "You are not allowed to do that." - defp error_message(:invalid_transition), do: "This activity state has already changed." - defp error_message(:not_open), do: "This activity is no longer open." - defp error_message(:rate_limited), do: "Too many actions in the configured time window." - defp error_message(%Ecto.Changeset{}), do: "Please check the submitted message." - defp error_message(reason), do: "Could not complete the action: #{inspect(reason)}" + defp error_message(:already_joined), + do: gettext("You already have a join request for this activity.") + + defp error_message(:capacity_reached), + do: gettext("The approved group has reached its capacity.") + + defp error_message(:join_closed), do: gettext("Join requests are closed.") + defp error_message(:blocked), do: gettext("This action is unavailable.") + defp error_message(:cannot_report_self), do: gettext("You cannot report your own content.") + defp error_message(:forbidden), do: gettext("You are not allowed to do that.") + + defp error_message(:invalid_transition), + do: gettext("This activity state has already changed.") + + defp error_message(:not_open), do: gettext("This activity is no longer open.") + + defp error_message(:rate_limited), + do: gettext("Too many actions in the configured time window.") + + defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted message.") + + defp error_message(reason), + do: gettext("Could not complete the action: %{reason}", reason: inspect(reason)) + + defp status_label(:open), do: gettext("Open") + defp status_label(:completed), do: gettext("Completed") + defp status_label(:cancelled), do: gettext("Cancelled") + defp status_label(value), do: value @impl true def render(assigns) do ~H""" <.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4"> - ← Back to activities + {gettext("← Back to activities")}
@@ -233,12 +300,15 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do {WhoNeedHelp.Catalog.category_path( @activity.category, - @current_scope.user.locale + Gettext.get_locale(WhoNeedHelpWeb.Gettext) )} - {@activity.status} + {status_label(@activity.status)} - {length(approved_participants(@activity))}/{@activity.capacity} approved + {gettext("%{approved}/%{capacity} approved", + approved: length(approved_participants(@activity)), + capacity: @activity.capacity + )}

{@activity.title}

@@ -246,30 +316,32 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
-
Starts
+
{gettext("Starts")}
- {Calendar.strftime(@activity.starts_at, "%d %b %Y, %H:%M UTC")} + {Calendar.strftime(@activity.starts_at, "%d.%m.%Y, %H:%M UTC")}
-
Join requests close
+
{gettext("Join requests close")}
- {Calendar.strftime(@activity.join_deadline, "%d %b %Y, %H:%M UTC")} + {Calendar.strftime(@activity.join_deadline, "%d.%m.%Y, %H:%M UTC")}
-
Meeting area
+
{gettext("Meeting area")}
{@activity.location_label}
-
Organizer
-
{@activity.creator.display_name || "Community member"}
+
{gettext("Organizer")}
+
+ {@activity.creator.display_name || gettext("Community member")} +
-
0} class="mt-5"> -

Plan details

+
+

{gettext("Plan details")}

-
{String.replace(key, "_", " ")}
-
{to_string(value)}
+
{label}
+
{value}
-

Approved group chat

+

{gettext("Approved group chat")}

- 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." + )}

- No messages yet. + {gettext("No messages yet.")}

- {message.sender.display_name || "Participant"} + {message.sender.display_name || gettext("Participant")}

{message.body}

@@ -343,10 +417,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do > <.input field={@message_form[:body]} - placeholder="Message the approved group" + placeholder={gettext("Message the approved group")} class="flex-1" /> - <.button class="btn btn-info self-end">Send + <.button class="btn btn-info self-end">{gettext("Send")}
@@ -360,12 +434,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do class="aid-map" />
-

Location privacy

+

{gettext("Location privacy")}

<%= if @approved? do %> - You are approved, so the map can show the exact meeting coordinates. + {gettext("You are approved, so the map can show the exact meeting coordinates.")} <% else %> - The public map is approximate or hidden. Exact coordinates appear only after approval. + {gettext( + "The public map is approximate or hidden. Exact coordinates appear only after approval." + )} <% end %>

@@ -376,12 +452,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do phx-click="join" class="btn btn-info w-full" > - Request to join + {gettext("Request to join")}
- Approval pending + {gettext("Approval pending")}

- The organizer has not approved your request yet. + {gettext("The organizer has not approved your request yet.")}

-

Organizer controls

+

{gettext("Organizer controls")}

-
{participant.user.display_name || "Community member"}
+
+ {participant.user.display_name || gettext("Community member")} +
{identity.provider} · {if identity.verified_at, - do: "verified", - else: "unverified"} + do: gettext("verified"), + else: gettext("unverified")}
@@ -427,38 +505,38 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do phx-value-id={participant.id} class="btn btn-info btn-xs" > - Approve + {gettext("Approve")}

- No pending join requests. + {gettext("No pending join requests.")}

-

Approved participants

+

{gettext("Approved participants")}

  • - {participant.user.display_name || "Community member"} + {participant.user.display_name || gettext("Community member")} - organizer + {gettext("organizer")}
    {identity.provider} · {if identity.verified_at, - do: "verified", - else: "unverified"} + do: gettext("verified"), + else: gettext("unverified")}
  • @@ -488,43 +566,49 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do :if={not @organizer? or @report_message_id} class="rounded-2xl border border-error/30 p-5" > -

    Safety controls

    +

    {gettext("Safety controls")}

    <.form for={@report_form} phx-submit="report" class="mt-4 space-y-2">
    - The report is scoped to the selected group message. + {gettext("The report is scoped to the selected group message.")}
    <.input field={@report_form[:reason]} type="select" - label="Reason" + label={gettext("Reason")} options={[ - {"Harassment", "harassment"}, - {"Dangerous plan", "dangerous_request"}, - {"Fraud", "fraud"}, - {"Spam", "spam"}, - {"Impersonation", "impersonation"}, - {"Other", "other"} + {gettext("Harassment"), "harassment"}, + {gettext("Dangerous plan"), "dangerous_request"}, + {gettext("Fraud"), "fraud"}, + {gettext("Spam"), "spam"}, + {gettext("Impersonation"), "impersonation"}, + {gettext("Other"), "other"} ]} /> - <.input field={@report_form[:details]} type="textarea" label="What happened?" /> - <.button class="btn btn-error btn-outline w-full">Send report + <.input + field={@report_form[:details]} + type="textarea" + label={gettext("What happened?")} + /> + <.button class="btn btn-error btn-outline w-full"> + {gettext("Send report")} +
diff --git a/lib/who_need_help_web/live/category_proposal_live.ex b/lib/who_need_help_web/live/category_proposal_live.ex index 1d0cd94..01a49ac 100644 --- a/lib/who_need_help_web/live/category_proposal_live.ex +++ b/lib/who_need_help_web/live/category_proposal_live.ex @@ -14,7 +14,9 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do case Catalog.propose(socket.assigns.current_scope, params) do {:ok, _} -> {:noreply, - socket |> put_flash(:info, "Proposal published for community voting.") |> load()} + socket + |> put_flash(:info, gettext("Proposal published for community voting.")) + |> load()} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} @@ -32,16 +34,20 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do end defp action_error(:account_not_eligible), - do: "Confirm your account and ensure it is active first." + do: gettext("Confirm your account and ensure it is active first.") - defp action_error(:rate_limited), do: "Too many actions in the configured time window." - defp action_error(:proposal_closed), do: "This proposal is already closed." - defp action_error(%Ecto.Changeset{}), do: "You already voted for this proposal." - defp action_error(reason), do: "Could not complete the action: #{inspect(reason)}" + defp action_error(:rate_limited), + do: gettext("Too many actions in the configured time window.") + + defp action_error(:proposal_closed), do: gettext("This proposal is already closed.") + defp action_error(%Ecto.Changeset{}), do: gettext("You already voted for this proposal.") + + defp action_error(reason), + do: gettext("Could not complete the action: %{reason}", reason: inspect(reason)) defp load(socket) do socket - |> assign(:page_title, "Category proposals") + |> assign(:page_title, gettext("Category proposals")) |> assign(:proposals, Catalog.list_proposals()) |> assign(:categories, Catalog.list_all_categories()) |> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{}))) @@ -53,56 +59,76 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
-
COMMUNITY-DRIVEN TAXONOMY
-

What help category is missing?

+
+ {gettext("COMMUNITY-DRIVEN TAXONOMY")} +
+

+ {gettext("What help category is missing?")} +

- Proposals and votes guide moderators. Approval remains a human decision. + {gettext("Proposals and votes guide moderators. Approval remains a human decision.")}

<.form for={@form} phx-submit="propose" class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6"> <.input field={@form[:proposed_name]} - label="Proposed category" - placeholder="Bicycle puncture" + label={gettext("Proposed category")} + placeholder={gettext("Bicycle puncture")} /> <.input field={@form[:mode]} type="select" - label="Mode" - options={[{"Urgent help", "help"}, {"Social activity", "activity"}]} + label={gettext("Mode")} + options={[ + {gettext("Urgent help"), "help"}, + {gettext("Social activity"), "activity"} + ]} /> <.input field={@form[:parent_id]} type="select" - label="Parent (optional)" - prompt="Top level" + label={gettext("Parent (optional)")} + prompt={gettext("Top level")} options={ Enum.map( @categories, - &{WhoNeedHelp.Catalog.Category.name(&1, @current_scope.user.locale), &1.id} + &{WhoNeedHelp.Catalog.Category.name( + &1, + Gettext.get_locale(WhoNeedHelpWeb.Gettext) + ), &1.id} ) } /> - <.input field={@form[:reason]} type="textarea" label="Why is this useful?" /> - <.button class="btn btn-primary w-full">Publish proposal + <.input + field={@form[:reason]} + type="textarea" + label={gettext("Why is this useful?")} + /> + <.button class="btn btn-primary w-full">{gettext("Publish proposal")}
-

Open proposals

+

{gettext("Open proposals")}

- No proposals yet. + {gettext("No proposals yet.")}

{proposal.proposed_name}

- {proposal.mode} + + {if proposal.mode == :help, + do: gettext("Urgent help"), + else: gettext("Social activity")} +

{proposal.reason}

-

by {proposal.proposer.display_name}

+

+ {gettext("by %{name}", name: proposal.proposer.display_name)} +