diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2e425b47b..a553bca41 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,10 +22,45 @@ env:
GITHUB_WORKFLOW: true
jobs:
+ ui:
+ name: UI library
+ environment: 'test'
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: packages/ui
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ with:
+ node-version: '18.x'
+ cache: 'yarn'
+
+ - name: Install dependencies on UI library
+ run: yarn install --frozen-lockfile
+
+ - name: Typecheck
+ run: yarn typecheck
+
+ - name: Lint CSS
+ run: yarn lint:css
+
+ - name: Lint JS
+ run: yarn lint:js
+
+ - name: build UI library
+ run: yarn build
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ui-build
+ path: packages/ui/dist
+
test:
name: Run tests
environment: 'test'
runs-on: ubuntu-latest
+ needs: [ui]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
@@ -33,15 +68,23 @@ jobs:
node-version: '18.x'
cache: 'yarn'
+ - uses: actions/download-artifact@v4
+ with:
+ name: ui-build
+ path: packages/ui/dist
+
- name: Install dependencies
- run: yarn install --prefer-offline --frozen-lockfile
+ run: yarn install --frozen-lockfile
+ working-directory: app
- name: Run test
-
run: yarn test
+ working-directory: app
+
translation:
name: Identify error with translation files
runs-on: ubuntu-latest
+ needs: [ui]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
@@ -49,14 +92,23 @@ jobs:
node-version: '18.x'
cache: 'yarn'
+ - uses: actions/download-artifact@v4
+ with:
+ name: ui-build
+ path: packages/ui/dist
+
- name: Install dependencies
- run: yarn install --prefer-offline --frozen-lockfile
+ run: yarn install --frozen-lockfile
+ working-directory: app
- name: Identify error with translation files
run: yarn lint:translation
+ working-directory: app
+
unimported:
name: Identify unused files
runs-on: ubuntu-latest
+ needs: [ui]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
@@ -64,14 +116,23 @@ jobs:
node-version: '18.x'
cache: 'yarn'
+ - uses: actions/download-artifact@v4
+ with:
+ name: ui-build
+ path: packages/ui/dist
+
- name: Install dependencies
- run: yarn install --prefer-offline --frozen-lockfile
+ run: yarn install --frozen-lockfile
+ working-directory: app
- name: Identify unused files
run: yarn lint:unused
+ working-directory: app
+
lint:
name: Lint JS
runs-on: ubuntu-latest
+ needs: [ui]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
@@ -79,14 +140,23 @@ jobs:
node-version: '18.x'
cache: 'yarn'
+ - uses: actions/download-artifact@v4
+ with:
+ name: ui-build
+ path: packages/ui/dist
+
- name: Install dependencies
- run: yarn install --prefer-offline --frozen-lockfile
+ run: yarn install --frozen-lockfile
+ working-directory: app
- name: Lint JS
run: yarn lint:js
+ working-directory: app
+
lint-css:
name: Lint CSS
runs-on: ubuntu-latest
+ needs: [ui]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
@@ -94,11 +164,19 @@ jobs:
node-version: '18.x'
cache: 'yarn'
+ - uses: actions/download-artifact@v4
+ with:
+ name: ui-build
+ path: packages/ui/dist
+
- name: Install dependencies
- run: yarn install --prefer-offline --frozen-lockfile
+ run: yarn install --frozen-lockfile
+ working-directory: app
- name: Lint CSS
run: yarn lint:css
+ working-directory: app
+
# FIXME: Identify a way to generate schema before we run typecheck
# typecheck:
# name: Typecheck
@@ -111,14 +189,14 @@ jobs:
# cache: 'yarn'
# - name: Install dependencies
- # run: yarn install --prefer-offline --frozen-lockfile
+ # run: yarn install --frozen-lockfile
# - name: Typecheck
# run: yarn typecheck
build:
name: Build
environment: 'test'
- needs: [lint, lint-css, test]
+ needs: [lint, lint-css, test, ui]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
@@ -127,8 +205,15 @@ jobs:
node-version: '18.x'
cache: 'yarn'
+ - uses: actions/download-artifact@v4
+ with:
+ name: ui-build
+ path: packages/ui/dist
+
- name: Install dependencies
- run: yarn install --prefer-offline --frozen-lockfile
+ run: yarn install --frozen-lockfile
+ working-directory: app
- name: Build
run: yarn build
+ working-directory: app
diff --git a/.dockerignore b/app/.dockerignore
similarity index 100%
rename from .dockerignore
rename to app/.dockerignore
diff --git a/.eslintignore b/app/.eslintignore
similarity index 100%
rename from .eslintignore
rename to app/.eslintignore
diff --git a/.unimportedrc.json b/app/.unimportedrc.json
similarity index 100%
rename from .unimportedrc.json
rename to app/.unimportedrc.json
diff --git a/Dockerfile b/app/Dockerfile
similarity index 100%
rename from Dockerfile
rename to app/Dockerfile
diff --git a/docker-compose.yml b/app/docker-compose.yml
similarity index 100%
rename from docker-compose.yml
rename to app/docker-compose.yml
diff --git a/env.ts b/app/env.ts
similarity index 100%
rename from env.ts
rename to app/env.ts
diff --git a/app/eslint.config.js b/app/eslint.config.js
new file mode 100644
index 000000000..891f8a8af
--- /dev/null
+++ b/app/eslint.config.js
@@ -0,0 +1,134 @@
+import { FlatCompat } from '@eslint/eslintrc';
+import js from '@eslint/js';
+import process from 'process';
+
+const dirname = process.cwd();
+
+const compat = new FlatCompat({
+ baseDirectory: dirname,
+ resolvePluginsRelativeTo: dirname,
+});
+
+const appConfigs = compat.config({
+ env: {
+ node: true,
+ browser: true,
+ es2020: true,
+ },
+ root: true,
+ extends: [
+ 'airbnb',
+ 'airbnb/hooks',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ ],
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ },
+ plugins: [
+ '@typescript-eslint',
+ 'react-refresh',
+ 'simple-import-sort',
+ 'import-newlines'
+ ],
+ settings: {
+ 'import/parsers': {
+ '@typescript-eslint/parser': ['.ts', '.tsx']
+ },
+ 'import/resolver': {
+ typescript: {
+ project: [
+ './tsconfig.json',
+ ],
+ },
+ },
+ },
+ rules: {
+ 'react-refresh/only-export-components': 'warn',
+
+ 'no-unused-vars': 0,
+ '@typescript-eslint/no-unused-vars': 1,
+
+ 'no-use-before-define': 0,
+ '@typescript-eslint/no-use-before-define': 1,
+
+ 'no-shadow': 0,
+ '@typescript-eslint/no-shadow': ['error'],
+
+ 'import/no-extraneous-dependencies': [
+ 'error',
+ {
+ devDependencies: [
+ '**/*.test.{ts,tsx}',
+ 'eslint.config.js',
+ 'postcss.config.cjs',
+ 'stylelint.config.cjs',
+ 'vite.config.ts',
+ ],
+ optionalDependencies: false,
+ },
+ ],
+
+ indent: ['error', 4, { SwitchCase: 1 }],
+
+ 'import/no-cycle': ['error', { allowUnsafeDynamicCyclicDependency: true }],
+
+ 'react/react-in-jsx-scope': 'off',
+ 'camelcase': 'off',
+
+ 'react/jsx-indent': ['error', 4],
+ 'react/jsx-indent-props': ['error', 4],
+ 'react/jsx-filename-extension': ['error', { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
+
+ 'import/extensions': ['off', 'never'],
+
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+
+ 'react/require-default-props': ['warn', { ignoreFunctionalComponents: true }],
+ 'simple-import-sort/imports': 'warn',
+ 'simple-import-sort/exports': 'warn',
+ 'import-newlines/enforce': ['warn', 1]
+ },
+ overrides: [
+ {
+ files: ['*.js', '*.jsx', '*.ts', '*.tsx'],
+ rules: {
+ 'simple-import-sort/imports': [
+ 'error',
+ {
+ 'groups': [
+ // side effect imports
+ ['^\\u0000'],
+ // packages `react` related packages come first
+ ['^react', '^@?\\w'],
+ // internal packages
+ ['^#.+$'],
+ // parent imports. Put `..` last
+ // other relative imports. Put same-folder imports and `.` last
+ ['^\\.\\.(?!/?$)', '^\\.\\./?$', '^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
+ // style imports
+ ['^.+\\.json$', '^.+\\.module.css$'],
+ ]
+ }
+ ]
+ }
+ }
+ ]
+}).map((conf) => ({
+ ...conf,
+ files: ['src/**/*.tsx', 'src/**/*.jsx', 'src/**/*.ts', 'src/**/*.js'],
+ ignores: ['src/generated/types.ts'],
+}));
+
+const otherConfig = {
+ files: ['*.js', '*.ts', '*.cjs'],
+ ...js.configs.recommended,
+};
+
+export default [
+ ...appConfigs,
+ otherConfig,
+];
diff --git a/index.html b/app/index.html
similarity index 100%
rename from index.html
rename to app/index.html
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 000000000..a6c55b0b0
--- /dev/null
+++ b/app/package.json
@@ -0,0 +1,110 @@
+{
+ "name": "go-web-app",
+ "version": "7.0.17",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "initialize:type": "mkdir -p generated/ && yarn initialize:type:go-api && yarn initialize:type:risk-api",
+ "initialize:type:go-api": "test -f ./generated/types.ts && true || cp types.stub.ts ./generated/types.ts",
+ "initialize:type:risk-api": "test -f ./generated/riskTypes.ts && true || cp types.stub.ts ./generated/riskTypes.ts",
+ "prestart": "yarn initialize:type",
+ "pretypecheck": "yarn initialize:type",
+ "prelint:js": "yarn initialize:type",
+ "prelint:unused": "yarn initialize:type",
+ "prebuild": "yarn initialize:type",
+ "start": "vite",
+ "build": "vite build",
+ "generate:type": "yarn generate:type:go-api && yarn generate:type:risk-api",
+ "generate:type:go-api": "dotenv -- cross-var openapi-typescript \"%APP_API_ENDPOINT%api-docs/\" -o ./generated/types.ts --alphabetize",
+ "generate:type:risk-api": "dotenv -- cross-var openapi-typescript \"%APP_RISK_API_ENDPOINT%api-docs/\" -o ./generated/riskTypes.ts --alphabetize",
+ "typecheck": "tsc",
+ "lint:js": "eslint src",
+ "lint:css": "stylelint \"./src/**/*.css\"",
+ "lint:unused": "unimported",
+ "lint:translation": "node ./scripts/translator.js",
+ "lint": "yarn lint:js && yarn lint:css && yarn lint:unused && yarn lint:translation",
+ "test": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "surge:deploy": "branch=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD); branch=$(echo $branch | tr ./ -); cp build/index.html build/200.html; surge -p build/ -d https://ifrc-go-$branch.surge.sh",
+ "surge:teardown": "branch=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD); branch=$(echo $branch | tr ./ -); surge teardown https://ifrc-go-$branch.surge.sh",
+ "postinstall": "patch-package"
+ },
+ "dependencies": {
+ "@ifrc-go/icons": "^1.2.0",
+ "@ifrc-go/ui": "^0.0.1",
+ "@mapbox/mapbox-gl-draw": "^1.2.0",
+ "@sentry/react": "^7.81.1",
+ "@tinymce/tinymce-react": "^4.3.0",
+ "@togglecorp/fujs": "^2.1.1",
+ "@togglecorp/re-map": "^0.2.0-beta-6",
+ "@togglecorp/toggle-form": "^2.0.4",
+ "@togglecorp/toggle-request": "^1.0.0-beta.2",
+ "@turf/bbox": "^6.5.0",
+ "@turf/buffer": "^6.5.0",
+ "exceljs": "^4.3.0",
+ "file-saver": "^2.0.5",
+ "html-to-image": "^1.11.11",
+ "mapbox-gl": "^1.13.0",
+ "papaparse": "^5.4.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-focus-on": "^3.8.1",
+ "react-router-dom": "^6.18.0",
+ "sanitize-html": "^2.10.0"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^2.0.3",
+ "@julr/vite-plugin-validate-env": "^1.0.1",
+ "@types/file-saver": "^2.0.5",
+ "@types/html2canvas": "^1.0.0",
+ "@types/mapbox-gl": "^1.13.0",
+ "@types/node": "^20.1.3",
+ "@types/papaparse": "^5.3.8",
+ "@types/react": "^18.0.28",
+ "@types/react-dom": "^18.0.11",
+ "@types/sanitize-html": "^2.9.0",
+ "@typescript-eslint/eslint-plugin": "^5.59.5",
+ "@typescript-eslint/parser": "^5.59.5",
+ "@vitejs/plugin-react-swc": "^3.5.0",
+ "autoprefixer": "^10.4.14",
+ "cross-var": "^1.1.0",
+ "dotenv-cli": "^7.2.1",
+ "eslint": "^8.40.0",
+ "eslint-config-airbnb": "^19.0.4",
+ "eslint-import-resolver-typescript": "^3.5.5",
+ "eslint-plugin-import": "^2.27.5",
+ "eslint-plugin-import-exports-imports-resolver": "^1.0.1",
+ "eslint-plugin-import-newlines": "^1.3.4",
+ "eslint-plugin-jsx-a11y": "^6.7.1",
+ "eslint-plugin-react": "^7.32.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.3.4",
+ "eslint-plugin-simple-import-sort": "^10.0.0",
+ "fast-glob": "^3.3.0",
+ "happy-dom": "^9.18.3",
+ "openapi-typescript": "6.5.5",
+ "patch-package": "^7.0.0",
+ "postcss": "^8.3.0",
+ "postcss-nested": "^6.0.1",
+ "postcss-normalize": "^10.0.1",
+ "postcss-preset-env": "^8.3.2",
+ "postinstall-postinstall": "^2.1.0",
+ "rollup-plugin-visualizer": "^5.9.0",
+ "stylelint": "^15.6.1",
+ "stylelint-config-concentric": "^2.0.2",
+ "stylelint-config-recommended": "^12.0.0",
+ "stylelint-no-unused-selectors": "git+https://github.com/toggle-corp/stylelint-no-unused-selectors#e0831e1",
+ "stylelint-value-no-unknown-custom-properties": "^4.0.0",
+ "surge": "^0.23.1",
+ "typescript": "^5.0.4",
+ "unimported": "1.28.0",
+ "vite": "^5.0.10",
+ "vite-plugin-checker": "^0.6.2",
+ "vite-plugin-compression2": "^0.11.0",
+ "vite-plugin-radar": "^0.9.2",
+ "vite-plugin-svgr": "^4.2.0",
+ "vite-plugin-webfont-dl": "^3.9.1",
+ "vite-tsconfig-paths": "^4.2.2",
+ "vitest": "^1.1.0"
+ }
+}
diff --git a/postcss.config.cjs b/app/postcss.config.cjs
similarity index 100%
rename from postcss.config.cjs
rename to app/postcss.config.cjs
diff --git a/public/go-icon.svg b/app/public/go-icon.svg
similarity index 100%
rename from public/go-icon.svg
rename to app/public/go-icon.svg
diff --git a/scripts/translator.js b/app/scripts/translator.js
similarity index 96%
rename from scripts/translator.js
rename to app/scripts/translator.js
index 8e5bac987..3f78c2091 100644
--- a/scripts/translator.js
+++ b/app/scripts/translator.js
@@ -1,9 +1,9 @@
+import { isDefined, listToMap, mapToList } from '@togglecorp/fujs';
import fg from 'fast-glob';
-import { cwd, exit } from 'process';
+import { readFile } from 'fs';
import { join } from 'path';
+import { cwd, exit } from 'process';
import { promisify } from 'util';
-import { readFile } from 'fs';
-import { mapToList, listToMap, isDefined, unique } from '@togglecorp/fujs';
const glob = fg.glob;
diff --git a/src/App/Auth.tsx b/app/src/App/Auth.tsx
similarity index 95%
rename from src/App/Auth.tsx
rename to app/src/App/Auth.tsx
index 4cf6b0f01..999e9dd14 100644
--- a/src/App/Auth.tsx
+++ b/app/src/App/Auth.tsx
@@ -1,12 +1,16 @@
-import { type ReactElement, Fragment } from 'react';
+import {
+ Fragment,
+ type ReactElement,
+} from 'react';
import {
Navigate,
useParams,
} from 'react-router-dom';
-import useAuth from '#hooks/domain/useAuth';
import FourHundredThree from '#components/FourHundredThree';
+import useAuth from '#hooks/domain/useAuth';
import usePermissions from '#hooks/domain/usePermissions';
+
import { type ExtendedProps } from './routes/common';
interface Props {
diff --git a/src/App/PageError/i18n.json b/app/src/App/PageError/i18n.json
similarity index 100%
rename from src/App/PageError/i18n.json
rename to app/src/App/PageError/i18n.json
diff --git a/app/src/App/PageError/index.tsx b/app/src/App/PageError/index.tsx
new file mode 100644
index 000000000..6a10d7acb
--- /dev/null
+++ b/app/src/App/PageError/index.tsx
@@ -0,0 +1,98 @@
+import {
+ useCallback,
+ useEffect,
+} from 'react';
+import { useRouteError } from 'react-router-dom';
+import { Button } from '@ifrc-go/ui';
+import {
+ useBooleanState,
+ useTranslation,
+} from '@ifrc-go/ui/hooks';
+
+import Link from '#components/Link';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+function PageError() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const errorResponse = useRouteError() as unknown as any;
+ const strings = useTranslation(i18n);
+
+ useEffect(
+ () => {
+ // eslint-disable-next-line no-console
+ console.error(errorResponse);
+ },
+ [errorResponse],
+ );
+
+ const [
+ fullErrorVisible,
+ {
+ toggle: toggleFullErrorVisibility,
+ },
+ ] = useBooleanState(false);
+
+ const handleReloadButtonClick = useCallback(
+ () => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ window.location.reload(true);
+ },
+ [],
+ );
+
+ return (
+
+
+
+
+ {strings.errorPageIssueMessage}
+
+
+ {errorResponse?.error?.message
+ ?? errorResponse?.message
+ ?? strings.errorPageUnexpectedMessage}
+
+
+ {fullErrorVisible && (
+ <>
+
+ {errorResponse?.error?.stack
+ ?? errorResponse?.stack ?? strings.errorPageStackTrace}
+
+
+ {strings.errorSeeDeveloperConsole}
+
+ >
+ )}
+
+
+ {/* NOTE: using the anchor element as it will refresh the page */}
+
+ {strings.errorPageGoBack}
+
+
+
+
+
+ );
+}
+
+export default PageError;
diff --git a/src/App/PageError/styles.module.css b/app/src/App/PageError/styles.module.css
similarity index 100%
rename from src/App/PageError/styles.module.css
rename to app/src/App/PageError/styles.module.css
diff --git a/app/src/App/index.tsx b/app/src/App/index.tsx
new file mode 100644
index 000000000..de0d0435b
--- /dev/null
+++ b/app/src/App/index.tsx
@@ -0,0 +1,269 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ createBrowserRouter,
+ RouterProvider,
+} from 'react-router-dom';
+import {
+ AlertContext,
+ type AlertContextProps,
+ type AlertParams,
+ type Language,
+ LanguageContext,
+ type LanguageContextProps,
+ type LanguageNamespaceStatus,
+} from '@ifrc-go/ui/contexts';
+import * as Sentry from '@sentry/react';
+import {
+ isDefined,
+ unique,
+} from '@togglecorp/fujs';
+import mapboxgl from 'mapbox-gl';
+
+import goLogo from '#assets/icons/go-logo-2020.svg';
+import {
+ appTitle,
+ mbtoken,
+} from '#config';
+import RouteContext from '#contexts/route';
+import UserContext, {
+ UserAuth,
+ UserContextProps,
+} from '#contexts/user';
+import {
+ KEY_LANGUAGE_STORAGE,
+ KEY_USER_STORAGE,
+} from '#utils/constants';
+import {
+ getFromStorage,
+ removeFromStorage,
+ setToStorage,
+} from '#utils/localStorage';
+import { RequestContext } from '#utils/restRequest';
+import {
+ processGoError,
+ processGoOptions,
+ processGoResponse,
+ processGoUrls,
+} from '#utils/restRequest/go';
+
+import wrappedRoutes, { unwrappedRoutes } from './routes';
+
+import styles from './styles.module.css';
+
+const requestContextValue = {
+ transformUrl: processGoUrls,
+ transformOptions: processGoOptions,
+ transformResponse: processGoResponse,
+ transformError: processGoError,
+};
+const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouter(createBrowserRouter);
+const router = sentryCreateBrowserRouter(unwrappedRoutes);
+mapboxgl.accessToken = mbtoken;
+mapboxgl.setRTLTextPlugin(
+ 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.2.3/mapbox-gl-rtl-text.js',
+ // eslint-disable-next-line no-console
+ (err) => { console.error(err); },
+ true,
+);
+
+function Application() {
+ // ALERTS
+
+ const [alerts, setAlerts] = useState([]);
+
+ const addAlert = useCallback((alert: AlertParams) => {
+ setAlerts((prevAlerts) => unique(
+ [...prevAlerts, alert],
+ (a) => a.name,
+ ) ?? prevAlerts);
+ }, [setAlerts]);
+
+ const removeAlert = useCallback((name: AlertParams['name']) => {
+ setAlerts((prevAlerts) => {
+ const i = prevAlerts.findIndex((a) => a.name === name);
+ if (i === -1) {
+ return prevAlerts;
+ }
+
+ const newAlerts = [...prevAlerts];
+ newAlerts.splice(i, 1);
+
+ return newAlerts;
+ });
+ }, [setAlerts]);
+
+ const updateAlert = useCallback((name: AlertParams['name'], paramsWithoutName: Omit) => {
+ setAlerts((prevAlerts) => {
+ const i = prevAlerts.findIndex((a) => a.name === name);
+ if (i === -1) {
+ return prevAlerts;
+ }
+
+ const updatedAlert = {
+ ...prevAlerts[i],
+ paramsWithoutName,
+ };
+
+ const newAlerts = [...prevAlerts];
+ newAlerts.splice(i, 1, updatedAlert);
+
+ return newAlerts;
+ });
+ }, [setAlerts]);
+
+ const alertContextValue: AlertContextProps = useMemo(() => ({
+ alerts,
+ addAlert,
+ updateAlert,
+ removeAlert,
+ }), [alerts, addAlert, updateAlert, removeAlert]);
+
+ // AUTH
+
+ const [userAuth, setUserAuth] = useState();
+
+ const hydrateUserAuth = useCallback(() => {
+ const userDetailsFromStorage = getFromStorage(KEY_USER_STORAGE);
+ if (userDetailsFromStorage) {
+ setUserAuth(userDetailsFromStorage);
+ }
+ }, []);
+
+ const removeUserAuth = useCallback(() => {
+ removeFromStorage(KEY_USER_STORAGE);
+ setUserAuth(undefined);
+ }, []);
+
+ const setAndStoreUserAuth = useCallback((newUserDetails: UserAuth) => {
+ setUserAuth(newUserDetails);
+ setToStorage(
+ KEY_USER_STORAGE,
+ newUserDetails,
+ );
+ }, []);
+
+ // Translation
+
+ const [strings, setStrings] = useState({});
+ const [currentLanguage, setCurrentLanguage] = useState('en');
+ const [
+ languageNamespaceStatus,
+ setLanguageNamespaceStatus,
+ ] = useState>({});
+
+ const setAndStoreCurrentLanguage = useCallback(
+ (newLanugage: Language) => {
+ setCurrentLanguage(newLanugage);
+ setToStorage(KEY_LANGUAGE_STORAGE, newLanugage);
+ },
+ [],
+ );
+
+ const registerLanguageNamespace = useCallback(
+ (namespace: string, fallbackStrings: Record) => {
+ setStrings(
+ (prevValue) => {
+ if (isDefined(prevValue[namespace])) {
+ return {
+ ...prevValue,
+ [namespace]: {
+ ...fallbackStrings,
+ ...prevValue[namespace],
+ },
+ };
+ }
+
+ return {
+ ...prevValue,
+ [namespace]: fallbackStrings,
+ };
+ },
+ );
+
+ setLanguageNamespaceStatus((prevValue) => {
+ if (isDefined(prevValue[namespace])) {
+ return prevValue;
+ }
+
+ return {
+ ...prevValue,
+ // NOTE: This will fetch if the data is not already fetched
+ [namespace]: prevValue[namespace] === 'fetched' ? 'fetched' : 'queued',
+ };
+ });
+ },
+ [setStrings],
+ );
+
+ // Hydration
+ useEffect(() => {
+ hydrateUserAuth();
+
+ const language = getFromStorage(KEY_LANGUAGE_STORAGE);
+ setCurrentLanguage(language ?? 'en');
+ }, [hydrateUserAuth]);
+
+ const userContextValue = useMemo(
+ () => ({
+ userAuth,
+ hydrateUserAuth,
+ setUserAuth: setAndStoreUserAuth,
+ removeUserAuth,
+ }),
+ [userAuth, hydrateUserAuth, setAndStoreUserAuth, removeUserAuth],
+ );
+
+ const languageContextValue = useMemo(
+ () => ({
+ languageNamespaceStatus,
+ setLanguageNamespaceStatus,
+ currentLanguage,
+ setCurrentLanguage: setAndStoreCurrentLanguage,
+ strings,
+ setStrings,
+ registerNamespace: registerLanguageNamespace,
+ }),
+ [
+ languageNamespaceStatus,
+ setLanguageNamespaceStatus,
+ currentLanguage,
+ setAndStoreCurrentLanguage,
+ strings,
+ registerLanguageNamespace,
+ ],
+ );
+
+ return (
+
+
+
+
+
+
+
+ {`${appTitle} loading...`}
+
+ )}
+ />
+
+
+
+
+
+ );
+}
+
+const App = Sentry.withProfiler(Application);
+export default App;
diff --git a/src/App/routes/CountryRoutes.tsx b/app/src/App/routes/CountryRoutes.tsx
similarity index 99%
rename from src/App/routes/CountryRoutes.tsx
rename to app/src/App/routes/CountryRoutes.tsx
index 1ecd80e7c..412216a24 100644
--- a/src/App/routes/CountryRoutes.tsx
+++ b/app/src/App/routes/CountryRoutes.tsx
@@ -1,9 +1,13 @@
import {
- Navigate,
generatePath,
+ Navigate,
useParams,
} from 'react-router-dom';
-import { isDefined, isTruthyString } from '@togglecorp/fujs';
+import {
+ isDefined,
+ isTruthyString,
+} from '@togglecorp/fujs';
+
import { countryIdToRegionIdMap } from '#utils/domain/country';
import Auth from '../Auth';
diff --git a/src/App/routes/RegionRoutes.tsx b/app/src/App/routes/RegionRoutes.tsx
similarity index 100%
rename from src/App/routes/RegionRoutes.tsx
rename to app/src/App/routes/RegionRoutes.tsx
diff --git a/src/App/routes/SmartNavigate.tsx b/app/src/App/routes/SmartNavigate.tsx
similarity index 93%
rename from src/App/routes/SmartNavigate.tsx
rename to app/src/App/routes/SmartNavigate.tsx
index 67f11e65e..965c08d8a 100644
--- a/src/App/routes/SmartNavigate.tsx
+++ b/app/src/App/routes/SmartNavigate.tsx
@@ -1,9 +1,12 @@
import {
Navigate,
- useLocation,
type NavigateProps,
+ useLocation,
} from 'react-router-dom';
-import { isDefined, isTruthyString } from '@togglecorp/fujs';
+import {
+ isDefined,
+ isTruthyString,
+} from '@togglecorp/fujs';
type RouteKey = string;
diff --git a/src/App/routes/SurgeRoutes.tsx b/app/src/App/routes/SurgeRoutes.tsx
similarity index 99%
rename from src/App/routes/SurgeRoutes.tsx
rename to app/src/App/routes/SurgeRoutes.tsx
index 1170f1bd9..45aedaa96 100644
--- a/src/App/routes/SurgeRoutes.tsx
+++ b/app/src/App/routes/SurgeRoutes.tsx
@@ -3,16 +3,18 @@ import {
Outlet,
useParams,
} from 'react-router-dom';
-import { isDefined, isTruthyString } from '@togglecorp/fujs';
+import {
+ isDefined,
+ isTruthyString,
+} from '@togglecorp/fujs';
import type { MyOutputNonIndexRouteObject } from '#utils/routes';
import Auth from '../Auth';
-
import {
customWrapRoute,
- rootLayout,
type ExtendedProps,
+ rootLayout,
} from './common';
type DefaultSurgeChild = 'overview';
diff --git a/app/src/App/routes/common.tsx b/app/src/App/routes/common.tsx
new file mode 100644
index 000000000..48cba20a1
--- /dev/null
+++ b/app/src/App/routes/common.tsx
@@ -0,0 +1,59 @@
+import {
+ type MyInputIndexRouteObject,
+ type MyInputNonIndexRouteObject,
+ type MyOutputIndexRouteObject,
+ type MyOutputNonIndexRouteObject,
+ wrapRoute,
+} from '#utils/routes';
+import { Component as RootLayout } from '#views/RootLayout';
+
+import Auth from '../Auth';
+import PageError from '../PageError';
+
+export interface Perms {
+ isDrefRegionalCoordinator: (regionId: number | undefined) => boolean,
+ isRegionAdmin: (regionId: number | undefined) => boolean,
+ isCountryAdmin: (countryId: number | undefined) => boolean,
+ isRegionPerAdmin: (regionId: number | undefined) => boolean,
+ isCountryPerAdmin: (countryId: number | undefined) => boolean,
+ isPerAdmin: boolean,
+ isIfrcAdmin: boolean,
+ isSuperUser: boolean,
+}
+
+export type ExtendedProps = {
+ title: string,
+ visibility: 'is-authenticated' | 'is-not-authenticated' | 'anything',
+ permissions?: (
+ permissions: Perms,
+ params: Record | undefined | null,
+ ) => boolean;
+};
+
+export interface CustomWrapRoute {
+ (
+ myRouteOptions: MyInputIndexRouteObject
+ ): MyOutputIndexRouteObject
+ (
+ myRouteOptions: MyInputNonIndexRouteObject
+ ): MyOutputNonIndexRouteObject
+}
+
+export const customWrapRoute: CustomWrapRoute = wrapRoute;
+
+// NOTE: We should not use layout or index routes in links
+
+export const rootLayout = customWrapRoute({
+ path: '/',
+ errorElement: ,
+ component: {
+ eagerLoad: true,
+ render: RootLayout,
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'IFRC GO',
+ visibility: 'anything',
+ },
+});
diff --git a/app/src/App/routes/index.tsx b/app/src/App/routes/index.tsx
new file mode 100644
index 000000000..830d16c15
--- /dev/null
+++ b/app/src/App/routes/index.tsx
@@ -0,0 +1,1218 @@
+import { Navigate } from 'react-router-dom';
+
+import { unwrapRoute } from '#utils/routes';
+
+import Auth from '../Auth';
+import {
+ customWrapRoute,
+ rootLayout,
+} from './common';
+import countryRoutes from './CountryRoutes';
+import regionRoutes from './RegionRoutes';
+import SmartNavigate from './SmartNavigate';
+import surgeRoutes from './SurgeRoutes';
+
+const fourHundredFour = customWrapRoute({
+ parent: rootLayout,
+ path: '*',
+ component: {
+ render: () => import('#views/FourHundredFour'),
+ props: {},
+ },
+ context: {
+ title: '404',
+ visibility: 'anything',
+ },
+});
+
+const login = customWrapRoute({
+ parent: rootLayout,
+ path: 'login',
+ component: {
+ render: () => import('#views/Login'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Login',
+ visibility: 'is-not-authenticated',
+ },
+});
+
+const register = customWrapRoute({
+ parent: rootLayout,
+ path: 'register',
+ component: {
+ render: () => import('#views/Register'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Register',
+ visibility: 'is-not-authenticated',
+ },
+});
+
+const recoverAccount = customWrapRoute({
+ parent: rootLayout,
+ path: 'recover-account',
+ component: {
+ render: () => import('#views/RecoverAccount'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Recover Account',
+ visibility: 'is-not-authenticated',
+ },
+});
+
+const recoverAccountConfirm = customWrapRoute({
+ parent: rootLayout,
+ path: 'recover-account/:username/:token',
+ component: {
+ render: () => import('#views/RecoverAccountConfirm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Recover Account Confirm',
+ visibility: 'is-not-authenticated',
+ },
+});
+
+const resendValidationEmail = customWrapRoute({
+ parent: rootLayout,
+ path: 'resend-validation-email',
+ component: {
+ render: () => import('#views/ResendValidationEmail'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Resend Validation Email',
+ visibility: 'is-not-authenticated',
+ },
+});
+
+const home = customWrapRoute({
+ parent: rootLayout,
+ index: true,
+ component: {
+ render: () => import('#views/Home'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Home',
+ visibility: 'anything',
+ },
+});
+
+const emergencies = customWrapRoute({
+ parent: rootLayout,
+ path: 'emergencies',
+ component: {
+ render: () => import('#views/Emergencies'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Emergencies',
+ visibility: 'anything',
+ },
+});
+
+type DefaultEmergenciesChild = 'details';
+const emergenciesLayout = customWrapRoute({
+ parent: rootLayout,
+ path: 'emergencies/:emergencyId',
+ forwardPath: 'details' satisfies DefaultEmergenciesChild,
+ component: {
+ render: () => import('#views/Emergency'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Emergency',
+ visibility: 'anything',
+ },
+});
+
+const emergencySlug = customWrapRoute({
+ parent: rootLayout,
+ path: 'emergencies/slug/:slug',
+ component: {
+ render: () => import('#views/EmergencySlug'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Emergency',
+ visibility: 'anything',
+ },
+});
+
+const emergencyFollow = customWrapRoute({
+ parent: rootLayout,
+ path: 'emergencies/:emergencyId/follow',
+ component: {
+ render: () => import('#views/EmergencyFollow'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Follow Emergency',
+ visibility: 'is-authenticated',
+ },
+});
+
+const emergencyIndex = customWrapRoute({
+ parent: emergenciesLayout,
+ index: true,
+ component: {
+ eagerLoad: true,
+ render: SmartNavigate,
+ props: {
+ to: 'details' satisfies DefaultEmergenciesChild,
+ replace: true,
+ hashToRouteMap: {
+ '#details': 'details',
+ '#reports': 'reports',
+ '#activities': 'activities',
+ '#surge': 'surge',
+ },
+ // TODO: make this typesafe
+ forwardUnmatchedHashTo: 'additional-info',
+ },
+ },
+ context: {
+ title: 'Emergency',
+ visibility: 'anything',
+ },
+});
+
+const emergencyDetails = customWrapRoute({
+ parent: emergenciesLayout,
+ path: 'details' satisfies DefaultEmergenciesChild,
+ component: {
+ render: () => import('#views/EmergencyDetails'),
+ props: {},
+ },
+ context: {
+ title: 'Emergency Details',
+ visibility: 'anything',
+ },
+});
+
+const emergencyReportsAndDocuments = customWrapRoute({
+ parent: emergenciesLayout,
+ path: 'reports',
+ component: {
+ render: () => import('#views/EmergencyReportAndDocument'),
+ props: {},
+ },
+ context: {
+ title: 'Emergency Reports and Documents',
+ visibility: 'anything',
+ },
+});
+
+const emergencyActivities = customWrapRoute({
+ parent: emergenciesLayout,
+ path: 'activities',
+ component: {
+ render: () => import('#views/EmergencyActivities'),
+ props: {},
+ },
+ context: {
+ title: 'Emergency Activities',
+ visibility: 'anything',
+ },
+});
+const emergencySurge = customWrapRoute({
+ parent: emergenciesLayout,
+ path: 'surge',
+ component: {
+ render: () => import('#views/EmergencySurge'),
+ props: {},
+ },
+ context: {
+ title: 'Emergency Surge',
+ visibility: 'anything',
+ },
+});
+
+// TODO: remove this route
+const emergencyAdditionalInfoOne = customWrapRoute({
+ parent: emergenciesLayout,
+ path: 'additional-info-1',
+ component: {
+ render: () => import('#views/EmergencyAdditionalTab'),
+ props: {
+ infoPageId: 1,
+ },
+ },
+ context: {
+ title: 'Emergency Additional Tab 1',
+ visibility: 'anything',
+ },
+});
+// TODO: remove this route
+const emergencyAdditionalInfoTwo = customWrapRoute({
+ parent: emergenciesLayout,
+ path: 'additional-info-2',
+ component: {
+ render: () => import('#views/EmergencyAdditionalTab'),
+ props: {
+ infoPageId: 2,
+ },
+ },
+ context: {
+ title: 'Emergency Additional Tab 2',
+ visibility: 'anything',
+ },
+});
+// TODO: remove this route
+const emergencyAdditionalInfoThree = customWrapRoute({
+ parent: emergenciesLayout,
+ path: 'additional-info-3',
+ component: {
+ render: () => import('#views/EmergencyAdditionalTab'),
+ props: {
+ infoPageId: 3,
+ },
+ },
+ context: {
+ title: 'Emergency Additional Tab 3',
+ visibility: 'anything',
+ },
+});
+
+const emergencyAdditionalInfo = customWrapRoute({
+ parent: emergenciesLayout,
+ path: 'additional-info/:tabId?',
+ component: {
+ render: () => import('#views/EmergencyAdditionalTab'),
+ props: {},
+ },
+ context: {
+ title: 'Emergency Additional Info Tab',
+ visibility: 'anything',
+ },
+});
+
+type DefaultPreparednessChild = 'global-summary';
+const preparednessLayout = customWrapRoute({
+ parent: rootLayout,
+ path: 'preparedness',
+ forwardPath: 'global-summary' satisfies DefaultPreparednessChild,
+ component: {
+ render: () => import('#views/Preparedness'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Preparedness',
+ visibility: 'anything',
+ },
+});
+
+const preparednessIndex = customWrapRoute({
+ parent: preparednessLayout,
+ index: true,
+ component: {
+ eagerLoad: true,
+ render: SmartNavigate,
+ props: {
+ to: 'global-summary' satisfies DefaultPreparednessChild,
+ replace: true,
+ hashToRouteMap: {
+ '#global-summary': 'global-summary',
+ '#global-performance': 'global-performance',
+ '#resources-catalogue': 'resources-catalogue',
+ '#operational-learning': 'operational-learning',
+ },
+ },
+ },
+ context: {
+ title: 'Preparedness',
+ visibility: 'anything',
+ },
+});
+
+const preparednessGlobalSummary = customWrapRoute({
+ parent: preparednessLayout,
+ path: 'global-summary' satisfies DefaultPreparednessChild,
+ component: {
+ render: () => import('#views/PreparednessGlobalSummary'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Preparedness - Global Summary',
+ visibility: 'anything',
+ },
+});
+
+const preparednessGlobalPerformance = customWrapRoute({
+ parent: preparednessLayout,
+ path: 'global-performance',
+ component: {
+ render: () => import('#views/PreparednessGlobalPerformance'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Preparedness - Global Performace',
+ visibility: 'anything',
+ },
+});
+
+const preparednessGlobalCatalogue = customWrapRoute({
+ parent: preparednessLayout,
+ path: 'resources-catalogue',
+ component: {
+ render: () => import('#views/PreparednessCatalogueResources'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Preparedness - Catalogue of Learning',
+ visibility: 'anything',
+ },
+});
+
+// FIXME: update name to `preparednessOperationalLearning`
+const preparednessGlobalOperational = customWrapRoute({
+ parent: preparednessLayout,
+ path: 'operational-learning',
+ component: {
+ render: () => import('#views/PreparednessOperationalLearning'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Preparedness - Operational Learning',
+ visibility: 'anything',
+ },
+});
+
+const globalThreeW = customWrapRoute({
+ parent: rootLayout,
+ path: 'three-w/projects',
+ component: {
+ render: () => import('#views/GlobalThreeW'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Global 3W',
+ visibility: 'anything',
+ },
+});
+
+const newThreeWProject = customWrapRoute({
+ parent: rootLayout,
+ path: 'three-w/projects/new',
+ component: {
+ render: () => import('#views/ThreeWProjectForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'New 3W Project',
+ visibility: 'is-authenticated',
+ },
+});
+
+const threeWProjectDetail = customWrapRoute({
+ parent: rootLayout,
+ path: 'three-w/projects/:projectId/',
+ component: {
+ render: () => import('#views/ThreeWProjectDetail'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: '3W Project Details',
+ visibility: 'anything',
+ },
+});
+
+const threeWProjectEdit = customWrapRoute({
+ parent: rootLayout,
+ path: 'three-w/projects/:projectId/edit',
+ component: {
+ render: () => import('#views/ThreeWProjectForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit 3W Project',
+ visibility: 'is-authenticated',
+ },
+});
+
+const newThreeWActivity = customWrapRoute({
+ parent: rootLayout,
+ path: 'three-w/activities/new',
+ component: {
+ render: () => import('#views/ThreeWActivityForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'New 3W Activity',
+ visibility: 'is-authenticated',
+ },
+});
+
+const threeWActivityDetail = customWrapRoute({
+ parent: rootLayout,
+ path: 'three-w/activities/:activityId',
+ component: {
+ render: () => import('#views/ThreeWActivityDetail'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: '3W Activity Detail',
+ visibility: 'anything',
+ },
+});
+
+const threeWActivityEdit = customWrapRoute({
+ parent: rootLayout,
+ path: 'three-w/activities/:activityId/edit',
+ component: {
+ render: () => import('#views/ThreeWActivityForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit 3W Activity',
+ visibility: 'is-authenticated',
+ },
+});
+
+type DefaultRiskWatchChild = 'seasonal';
+const riskWatchLayout = customWrapRoute({
+ parent: rootLayout,
+ path: 'risk-watch',
+ forwardPath: 'seasonal' satisfies DefaultRiskWatchChild,
+ component: {
+ render: () => import('#views/RiskWatch'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Risk Watch',
+ visibility: 'anything',
+ },
+});
+
+const riskWatchIndex = customWrapRoute({
+ parent: riskWatchLayout,
+ index: true,
+ component: {
+ eagerLoad: true,
+ render: Navigate,
+ props: {
+ to: 'seasonal' satisfies DefaultRiskWatchChild,
+ replace: true,
+ },
+ },
+ context: {
+ title: 'Risk Watch',
+ visibility: 'anything',
+ },
+});
+
+const riskWatchSeasonal = customWrapRoute({
+ parent: riskWatchLayout,
+ path: 'seasonal' satisfies DefaultRiskWatchChild,
+ component: {
+ render: () => import('#views/RiskWatchSeasonal'),
+ props: {},
+ },
+ context: {
+ title: 'Seasonal Risk Watch',
+ visibility: 'anything',
+ },
+});
+
+const riskWatchImminent = customWrapRoute({
+ parent: riskWatchLayout,
+ path: 'imminent',
+ component: {
+ render: () => import('#views/RiskWatchImminent'),
+ props: {},
+ },
+ context: {
+ title: 'Imminent Risk Watch',
+ visibility: 'anything',
+ },
+});
+
+type DefaultAccountChild = 'details';
+const accountLayout = customWrapRoute({
+ parent: rootLayout,
+ path: 'account',
+ forwardPath: 'details' satisfies DefaultAccountChild,
+ component: {
+ render: () => import('#views/Account'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Account',
+ visibility: 'is-authenticated',
+ },
+});
+
+const accountIndex = customWrapRoute({
+ parent: accountLayout,
+ index: true,
+ component: {
+ eagerLoad: true,
+ render: SmartNavigate,
+ props: {
+ to: 'details' satisfies DefaultAccountChild,
+ replace: true,
+ hashToRouteMap: {
+ '#account-information': 'details',
+ '#notifications': 'notifications',
+ '#per-forms': 'my-forms/per',
+ '#my-dref-applications': 'my-forms/dref',
+ '#three-w-forms': 'my-forms/three-w',
+ },
+ },
+ },
+ context: {
+ title: 'Account',
+ visibility: 'anything',
+ },
+});
+
+const accountDetails = customWrapRoute({
+ parent: accountLayout,
+ path: 'details' satisfies DefaultAccountChild,
+ component: {
+ render: () => import('#views/AccountDetails'),
+ props: {},
+ },
+ context: {
+ title: 'Account Details',
+ visibility: 'is-authenticated',
+ },
+});
+
+type DefaultAccountMyFormsChild = 'field-report';
+const accountMyFormsLayout = customWrapRoute({
+ parent: accountLayout,
+ path: 'my-forms',
+ forwardPath: 'field-report' satisfies DefaultAccountMyFormsChild,
+ component: {
+ render: () => import('#views/AccountMyFormsLayout'),
+ props: {},
+ },
+ context: {
+ title: 'Account - My Forms',
+ visibility: 'is-authenticated',
+ },
+});
+
+const accountMyFormsIndex = customWrapRoute({
+ parent: accountMyFormsLayout,
+ index: true,
+ component: {
+ eagerLoad: true,
+ render: Navigate,
+ props: {
+ to: 'field-report' satisfies DefaultAccountMyFormsChild,
+ replace: true,
+ },
+ },
+ context: {
+ title: 'Account - My Forms',
+ visibility: 'anything',
+ },
+});
+
+const accountMyFormsFieldReport = customWrapRoute({
+ parent: accountMyFormsLayout,
+ path: 'field-report' satisfies DefaultAccountMyFormsChild,
+ component: {
+ render: () => import('#views/AccountMyFormsFieldReport'),
+ props: {},
+ },
+ context: {
+ title: 'Account - Field Report Forms',
+ visibility: 'is-authenticated',
+ },
+});
+
+const accountMyFormsPer = customWrapRoute({
+ parent: accountMyFormsLayout,
+ path: 'per',
+ component: {
+ render: () => import('#views/AccountMyFormsPer'),
+ props: {},
+ },
+ context: {
+ title: 'Account - PER Forms',
+ visibility: 'is-authenticated',
+ },
+});
+
+const accountMyFormsDref = customWrapRoute({
+ parent: accountMyFormsLayout,
+ path: 'dref',
+ component: {
+ render: () => import('#views/AccountMyFormsDref'),
+ props: {},
+ },
+ context: {
+ title: 'Account - DREF Applications',
+ visibility: 'is-authenticated',
+ },
+});
+
+const accountMyFormsThreeW = customWrapRoute({
+ parent: accountMyFormsLayout,
+ path: 'three-w',
+ component: {
+ render: () => import('#views/AccountMyFormsThreeW'),
+ props: {},
+ },
+ context: {
+ title: 'Account - 3W',
+ visibility: 'is-authenticated',
+ },
+});
+
+const accountNotifications = customWrapRoute({
+ parent: accountLayout,
+ path: 'notifications',
+ component: {
+ render: () => import('#views/AccountNotifications'),
+ props: {},
+ },
+ context: {
+ title: 'Account - Notifications',
+ visibility: 'is-authenticated',
+ },
+});
+
+const resources = customWrapRoute({
+ parent: rootLayout,
+ path: 'resources',
+ component: {
+ render: () => import('#views/Resources'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Resources',
+ visibility: 'anything',
+ },
+});
+
+const search = customWrapRoute({
+ parent: rootLayout,
+ path: 'search',
+ component: {
+ render: () => import('#views/Search'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Search',
+ visibility: 'anything',
+ },
+});
+
+const allThreeWProject = customWrapRoute({
+ parent: rootLayout,
+ path: 'three-w/projects/all',
+ component: {
+ render: () => import('#views/AllThreeWProject'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'All 3W Projects',
+ visibility: 'anything',
+ },
+});
+
+const allThreeWActivity = customWrapRoute({
+ parent: rootLayout,
+ path: 'three-w/activities/all',
+ component: {
+ render: () => import('#views/AllThreeWActivity'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'All 3W Activities',
+ visibility: 'anything',
+ },
+});
+
+const allAppeals = customWrapRoute({
+ parent: rootLayout,
+ path: 'appeals/all',
+ component: {
+ render: () => import('#views/AllAppeals'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'All Appeals',
+ visibility: 'anything',
+ },
+});
+
+const allEmergencies = customWrapRoute({
+ parent: rootLayout,
+ path: 'emergencies/all',
+ component: {
+ render: () => import('#views/AllEmergencies'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'All Emergencies',
+ visibility: 'anything',
+ },
+});
+
+const allFieldReports = customWrapRoute({
+ parent: rootLayout,
+ path: 'field-reports/all',
+ component: {
+ render: () => import('#views/AllFieldReports'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'All Field Reports',
+ visibility: 'anything',
+ },
+});
+
+const allFlashUpdates = customWrapRoute({
+ parent: rootLayout,
+ path: 'flash-updates/all',
+ component: {
+ render: () => import('#views/AllFlashUpdates'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'All Flash Updates',
+ visibility: 'is-authenticated',
+ permissions: ({ isIfrcAdmin }) => isIfrcAdmin,
+ },
+});
+
+const flashUpdateFormNew = customWrapRoute({
+ parent: rootLayout,
+ path: 'flash-updates/new',
+ component: {
+ render: () => import('#views/FlashUpdateForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'New Flash Update',
+ visibility: 'is-authenticated',
+ permissions: ({ isIfrcAdmin }) => isIfrcAdmin,
+ },
+});
+
+const flashUpdateFormEdit = customWrapRoute({
+ parent: rootLayout,
+ path: 'flash-updates/:flashUpdateId/edit',
+ component: {
+ render: () => import('#views/FlashUpdateForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit Flash Update',
+ visibility: 'is-authenticated',
+ permissions: ({ isIfrcAdmin }) => isIfrcAdmin,
+ },
+});
+
+// FIXME: rename this route to flashUpdateDetails
+const flashUpdateFormDetails = customWrapRoute({
+ parent: rootLayout,
+ path: 'flash-updates/:flashUpdateId',
+ component: {
+ render: () => import('#views/FlashUpdateDetails'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Flash Update Details',
+ visibility: 'anything',
+ permissions: ({ isIfrcAdmin }) => isIfrcAdmin,
+ },
+});
+
+const allSurgeAlerts = customWrapRoute({
+ parent: rootLayout,
+ path: 'alerts/all',
+ component: {
+ render: () => import('#views/AllSurgeAlerts'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'All Surge Alerts',
+ visibility: 'anything',
+ },
+});
+
+const newDrefApplicationForm = customWrapRoute({
+ parent: rootLayout,
+ path: 'dref-applications/new',
+ component: {
+ render: () => import('#views/DrefApplicationForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'New DREF Application Form',
+ visibility: 'is-authenticated',
+ },
+});
+
+const drefApplicationForm = customWrapRoute({
+ parent: rootLayout,
+ path: 'dref-applications/:drefId/edit',
+ component: {
+ render: () => import('#views/DrefApplicationForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit DREF Application Form',
+ visibility: 'is-authenticated',
+ },
+});
+
+const drefApplicationExport = customWrapRoute({
+ path: 'dref-applications/:drefId/export',
+ component: {
+ render: () => import('#views/DrefApplicationExport'),
+ props: {},
+ },
+ parent: rootLayout,
+ wrapperComponent: Auth,
+ context: {
+ title: 'DREF Application Export',
+ visibility: 'is-authenticated',
+ },
+});
+
+const drefOperationalUpdateForm = customWrapRoute({
+ parent: rootLayout,
+ path: 'dref-operational-updates/:opsUpdateId/edit',
+ component: {
+ render: () => import('#views/DrefOperationalUpdateForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit DREF Operational Update Form',
+ visibility: 'is-authenticated',
+ },
+});
+
+const drefOperationalUpdateExport = customWrapRoute({
+ path: 'dref-operational-updates/:opsUpdateId/export',
+ component: {
+ render: () => import('#views/DrefOperationalUpdateExport'),
+ props: {},
+ },
+ parent: rootLayout,
+ wrapperComponent: Auth,
+ context: {
+ title: 'DREF Operational Update Export',
+ visibility: 'is-authenticated',
+ },
+});
+const drefFinalReportForm = customWrapRoute({
+ parent: rootLayout,
+ path: 'dref-final-reports/:finalReportId/edit',
+ component: {
+ render: () => import('#views/DrefFinalReportForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit DREF Final Report Form',
+ visibility: 'is-authenticated',
+ },
+});
+
+const drefFinalReportExport = customWrapRoute({
+ path: 'dref-final-reports/:finalReportId/export',
+ component: {
+ render: () => import('#views/DrefFinalReportExport'),
+ props: {},
+ },
+ parent: rootLayout,
+ wrapperComponent: Auth,
+ context: {
+ title: 'DREF Final Report Export',
+ visibility: 'is-authenticated',
+ },
+});
+
+const fieldReportFormNew = customWrapRoute({
+ parent: rootLayout,
+ path: 'field-reports/new',
+ component: {
+ render: () => import('#views/FieldReportForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'New Field Report Form',
+ visibility: 'is-authenticated',
+ },
+});
+
+const fieldReportFormEdit = customWrapRoute({
+ parent: rootLayout,
+ path: 'field-reports/:fieldReportId/edit',
+ component: {
+ render: () => import('#views/FieldReportForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit Field Report Form',
+ visibility: 'is-authenticated',
+ },
+});
+
+const fieldReportDetails = customWrapRoute({
+ parent: rootLayout,
+ path: 'field-reports/:fieldReportId',
+ component: {
+ render: () => import('#views/FieldReportDetails'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Field Report Details',
+ visibility: 'anything',
+ },
+});
+
+type DefaultPerProcessChild = 'new';
+const perProcessLayout = customWrapRoute({
+ parent: rootLayout,
+ path: 'per-process',
+ forwardPath: 'new' satisfies DefaultPerProcessChild,
+ component: {
+ render: () => import('#views/PerProcessForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'PER Process',
+ visibility: 'is-authenticated',
+ },
+});
+
+const perProcessFormIndex = customWrapRoute({
+ parent: perProcessLayout,
+ index: true,
+ component: {
+ eagerLoad: true,
+ render: Navigate,
+ props: {
+ to: 'new' satisfies DefaultPerProcessChild,
+ replace: true,
+ },
+ },
+ context: {
+ title: 'PER Process',
+ visibility: 'anything',
+ },
+});
+
+const newPerOverviewForm = customWrapRoute({
+ parent: perProcessLayout,
+ path: 'new' satisfies DefaultPerProcessChild,
+ component: {
+ render: () => import('#views/PerOverviewForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'New PER Overview',
+ visibility: 'is-authenticated',
+ permissions: ({
+ isSuperUser,
+ isPerAdmin,
+ }) => isSuperUser || isPerAdmin,
+ },
+});
+
+const perOverviewForm = customWrapRoute({
+ parent: perProcessLayout,
+ path: ':perId/overview',
+ component: {
+ render: () => import('#views/PerOverviewForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit PER Overview',
+ visibility: 'is-authenticated',
+ },
+});
+
+const perAssessmentForm = customWrapRoute({
+ parent: perProcessLayout,
+ path: ':perId/assessment',
+ component: {
+ render: () => import('#views/PerAssessmentForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit PER Assessment',
+ visibility: 'is-authenticated',
+ },
+});
+
+const perPrioritizationForm = customWrapRoute({
+ parent: perProcessLayout,
+ path: ':perId/prioritization',
+ component: {
+ render: () => import('#views/PerPrioritizationForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit PER Prioritization',
+ visibility: 'is-authenticated',
+ },
+});
+
+const perWorkPlanForm = customWrapRoute({
+ parent: perProcessLayout,
+ path: ':perId/work-plan',
+ component: {
+ render: () => import('#views/PerWorkPlanForm'),
+ props: {},
+ },
+ wrapperComponent: Auth,
+ context: {
+ title: 'Edit PER Work Plan',
+ visibility: 'is-authenticated',
+ },
+});
+
+// Redirect Routes
+
+const wrappedRoutes = {
+ fourHundredFour,
+ rootLayout,
+ login,
+ register,
+ recoverAccount,
+ recoverAccountConfirm,
+ resendValidationEmail,
+ home,
+ emergencies,
+ emergencySlug,
+ emergencyFollow,
+ emergenciesLayout,
+ emergencyDetails,
+ emergencyIndex,
+ emergencyReportsAndDocuments,
+ emergencyActivities,
+ emergencySurge,
+ emergencyAdditionalInfoOne,
+ emergencyAdditionalInfoTwo,
+ emergencyAdditionalInfoThree,
+ emergencyAdditionalInfo,
+ preparednessLayout,
+ preparednessGlobalSummary,
+ preparednessGlobalPerformance,
+ preparednessGlobalCatalogue,
+ preparednessGlobalOperational,
+ preparednessIndex,
+ perProcessFormIndex,
+ globalThreeW,
+ newThreeWProject,
+ threeWProjectEdit,
+ threeWActivityEdit,
+ threeWActivityDetail,
+ newThreeWActivity,
+ accountLayout,
+ accountIndex,
+ accountDetails,
+ accountMyFormsLayout,
+ accountMyFormsIndex,
+ accountNotifications,
+ accountMyFormsFieldReport,
+ accountMyFormsPer,
+ accountMyFormsDref,
+ accountMyFormsThreeW,
+ resources,
+ search,
+ allThreeWProject,
+ allThreeWActivity,
+ allAppeals,
+ allEmergencies,
+ allFieldReports,
+ allSurgeAlerts,
+ allFlashUpdates,
+ newDrefApplicationForm,
+ drefApplicationForm,
+ drefApplicationExport,
+ drefOperationalUpdateForm,
+ drefOperationalUpdateExport,
+ drefFinalReportForm,
+ drefFinalReportExport,
+ fieldReportFormNew,
+ fieldReportFormEdit,
+ fieldReportDetails,
+ flashUpdateFormNew,
+ flashUpdateFormDetails,
+ flashUpdateFormEdit,
+ riskWatchLayout,
+ riskWatchIndex,
+ riskWatchImminent,
+ riskWatchSeasonal,
+ perProcessLayout,
+ perOverviewForm,
+ newPerOverviewForm,
+ perAssessmentForm,
+ perPrioritizationForm,
+ perWorkPlanForm,
+ threeWProjectDetail,
+
+ ...regionRoutes,
+ ...countryRoutes,
+ ...surgeRoutes,
+
+};
+
+export const unwrappedRoutes = unwrapRoute(Object.values(wrappedRoutes));
+
+export default wrappedRoutes;
+
+export type WrappedRoutes = typeof wrappedRoutes;
diff --git a/src/App/styles.module.css b/app/src/App/styles.module.css
similarity index 100%
rename from src/App/styles.module.css
rename to app/src/App/styles.module.css
diff --git a/src/assets/content/operational_timeline_body.svg b/app/src/assets/content/operational_timeline_body.svg
similarity index 100%
rename from src/assets/content/operational_timeline_body.svg
rename to app/src/assets/content/operational_timeline_body.svg
diff --git a/src/assets/content/operational_timeline_title.svg b/app/src/assets/content/operational_timeline_title.svg
similarity index 100%
rename from src/assets/content/operational_timeline_title.svg
rename to app/src/assets/content/operational_timeline_title.svg
diff --git a/src/assets/content/per_approach_notext.svg b/app/src/assets/content/per_approach_notext.svg
similarity index 100%
rename from src/assets/content/per_approach_notext.svg
rename to app/src/assets/content/per_approach_notext.svg
diff --git a/src/assets/icons/arc_logo.png b/app/src/assets/icons/arc_logo.png
similarity index 100%
rename from src/assets/icons/arc_logo.png
rename to app/src/assets/icons/arc_logo.png
diff --git a/src/assets/icons/aurc_logo.svg b/app/src/assets/icons/aurc_logo.svg
similarity index 100%
rename from src/assets/icons/aurc_logo.svg
rename to app/src/assets/icons/aurc_logo.svg
diff --git a/src/assets/icons/brc_logo.png b/app/src/assets/icons/brc_logo.png
similarity index 100%
rename from src/assets/icons/brc_logo.png
rename to app/src/assets/icons/brc_logo.png
diff --git a/src/assets/icons/crc_logo.png b/app/src/assets/icons/crc_logo.png
similarity index 100%
rename from src/assets/icons/crc_logo.png
rename to app/src/assets/icons/crc_logo.png
diff --git a/src/assets/icons/dnk_logo.png b/app/src/assets/icons/dnk_logo.png
similarity index 100%
rename from src/assets/icons/dnk_logo.png
rename to app/src/assets/icons/dnk_logo.png
diff --git a/src/assets/icons/ericsson_logo.png b/app/src/assets/icons/ericsson_logo.png
similarity index 100%
rename from src/assets/icons/ericsson_logo.png
rename to app/src/assets/icons/ericsson_logo.png
diff --git a/src/assets/icons/eru.jpg b/app/src/assets/icons/eru.jpg
similarity index 100%
rename from src/assets/icons/eru.jpg
rename to app/src/assets/icons/eru.jpg
diff --git a/src/assets/icons/esp_logo.svg b/app/src/assets/icons/esp_logo.svg
similarity index 100%
rename from src/assets/icons/esp_logo.svg
rename to app/src/assets/icons/esp_logo.svg
diff --git a/src/assets/icons/frc_logo.png b/app/src/assets/icons/frc_logo.png
similarity index 100%
rename from src/assets/icons/frc_logo.png
rename to app/src/assets/icons/frc_logo.png
diff --git a/src/assets/icons/go-logo-2020.svg b/app/src/assets/icons/go-logo-2020.svg
similarity index 100%
rename from src/assets/icons/go-logo-2020.svg
rename to app/src/assets/icons/go-logo-2020.svg
diff --git a/src/assets/icons/ifrc-square.png b/app/src/assets/icons/ifrc-square.png
similarity index 100%
rename from src/assets/icons/ifrc-square.png
rename to app/src/assets/icons/ifrc-square.png
diff --git a/src/assets/icons/jrc_logo.png b/app/src/assets/icons/jrc_logo.png
similarity index 100%
rename from src/assets/icons/jrc_logo.png
rename to app/src/assets/icons/jrc_logo.png
diff --git a/src/assets/icons/nlrc_logo.jpg b/app/src/assets/icons/nlrc_logo.jpg
similarity index 100%
rename from src/assets/icons/nlrc_logo.jpg
rename to app/src/assets/icons/nlrc_logo.jpg
diff --git a/src/assets/icons/pdc_logo.svg b/app/src/assets/icons/pdc_logo.svg
similarity index 100%
rename from src/assets/icons/pdc_logo.svg
rename to app/src/assets/icons/pdc_logo.svg
diff --git a/src/assets/icons/risk/cyclone.png b/app/src/assets/icons/risk/cyclone.png
similarity index 100%
rename from src/assets/icons/risk/cyclone.png
rename to app/src/assets/icons/risk/cyclone.png
diff --git a/src/assets/icons/risk/drought.png b/app/src/assets/icons/risk/drought.png
similarity index 100%
rename from src/assets/icons/risk/drought.png
rename to app/src/assets/icons/risk/drought.png
diff --git a/src/assets/icons/risk/earthquake.png b/app/src/assets/icons/risk/earthquake.png
similarity index 100%
rename from src/assets/icons/risk/earthquake.png
rename to app/src/assets/icons/risk/earthquake.png
diff --git a/src/assets/icons/risk/flood.png b/app/src/assets/icons/risk/flood.png
similarity index 100%
rename from src/assets/icons/risk/flood.png
rename to app/src/assets/icons/risk/flood.png
diff --git a/src/assets/icons/risk/wildfire.png b/app/src/assets/icons/risk/wildfire.png
similarity index 100%
rename from src/assets/icons/risk/wildfire.png
rename to app/src/assets/icons/risk/wildfire.png
diff --git a/src/assets/icons/swiss.svg b/app/src/assets/icons/swiss.svg
similarity index 100%
rename from src/assets/icons/swiss.svg
rename to app/src/assets/icons/swiss.svg
diff --git a/src/assets/icons/us_aid.svg b/app/src/assets/icons/us_aid.svg
similarity index 100%
rename from src/assets/icons/us_aid.svg
rename to app/src/assets/icons/us_aid.svg
diff --git a/src/assets/images/surge-im-composition.jpg b/app/src/assets/images/surge-im-composition.jpg
similarity index 100%
rename from src/assets/images/surge-im-composition.jpg
rename to app/src/assets/images/surge-im-composition.jpg
diff --git a/src/assets/images/surge-im-pyramid.png b/app/src/assets/images/surge-im-pyramid.png
similarity index 100%
rename from src/assets/images/surge-im-pyramid.png
rename to app/src/assets/images/surge-im-pyramid.png
diff --git a/src/assets/images/surge-im-support-responsible-operation.jpg b/app/src/assets/images/surge-im-support-responsible-operation.jpg
similarity index 100%
rename from src/assets/images/surge-im-support-responsible-operation.jpg
rename to app/src/assets/images/surge-im-support-responsible-operation.jpg
diff --git a/app/src/components/CatalogueInfoCard/index.tsx b/app/src/components/CatalogueInfoCard/index.tsx
new file mode 100644
index 000000000..d2b522a60
--- /dev/null
+++ b/app/src/components/CatalogueInfoCard/index.tsx
@@ -0,0 +1,83 @@
+import { useCallback } from 'react';
+import {
+ Container,
+ List,
+} from '@ifrc-go/ui';
+import { _cs } from '@togglecorp/fujs';
+
+import Link, { Props as LinkProps } from '#components/Link';
+
+import styles from './styles.module.css';
+
+export type LinkData = LinkProps & {
+ title: string;
+}
+
+const catalogueInfoKeySelector = (item: LinkData) => item.title;
+interface Props {
+ className?: string;
+ title: string;
+ data: LinkData[];
+ description?: string;
+ descriptionClassName?: string;
+}
+
+function CatalogueInfoCard(props: Props) {
+ const {
+ className,
+ title,
+ data,
+ description,
+ descriptionClassName,
+ } = props;
+
+ const rendererParams = useCallback(
+ (_: string, value: LinkData): LinkProps => {
+ if (value.external) {
+ return {
+ href: value.href,
+ children: value.title,
+ external: true,
+ withLinkIcon: value.withLinkIcon,
+ };
+ }
+
+ return {
+ to: value.to,
+ urlParams: value.urlParams,
+ urlSearch: value.urlSearch,
+ urlHash: value.urlHash,
+ children: value.title,
+ withLinkIcon: value.withLinkIcon,
+ };
+ },
+ [],
+ );
+
+ return (
+
+
+
+ );
+}
+
+export default CatalogueInfoCard;
diff --git a/src/components/CatalogueInfoCard/styles.module.css b/app/src/components/CatalogueInfoCard/styles.module.css
similarity index 100%
rename from src/components/CatalogueInfoCard/styles.module.css
rename to app/src/components/CatalogueInfoCard/styles.module.css
diff --git a/app/src/components/DropdownMenuItem/index.tsx b/app/src/components/DropdownMenuItem/index.tsx
new file mode 100644
index 000000000..24b6ac22f
--- /dev/null
+++ b/app/src/components/DropdownMenuItem/index.tsx
@@ -0,0 +1,125 @@
+import {
+ useCallback,
+ useContext,
+} from 'react';
+import {
+ Button,
+ ButtonProps,
+ ConfirmButton,
+ ConfirmButtonProps,
+} from '@ifrc-go/ui';
+import { DropdownMenuContext } from '@ifrc-go/ui/contexts';
+import { isDefined } from '@togglecorp/fujs';
+
+import Link, { Props as LinkProps } from '#components/Link';
+
+type CommonProp = {
+ persist?: boolean;
+}
+
+type ButtonTypeProps = Omit, 'type'> & {
+ type: 'button';
+}
+
+type LinkTypeProps = LinkProps & {
+ type: 'link';
+}
+
+type ConfirmButtonTypeProps = Omit, 'type'> & {
+ type: 'confirm-button',
+}
+
+type Props = CommonProp & (ButtonTypeProps | LinkTypeProps | ConfirmButtonTypeProps);
+
+function DropdownMenuItem(props: Props) {
+ const {
+ type,
+ onClick,
+ persist = false,
+ } = props;
+ const { setShowDropdown } = useContext(DropdownMenuContext);
+
+ const handleLinkClick = useCallback(
+ () => {
+ if (!persist) {
+ setShowDropdown(false);
+ }
+ // TODO: maybe add onClick here?
+ },
+ [setShowDropdown, persist],
+ );
+
+ const handleButtonClick = useCallback(
+ (name: NAME, e: React.MouseEvent) => {
+ if (!persist) {
+ setShowDropdown(false);
+ }
+ if (isDefined(onClick) && type !== 'link') {
+ onClick(name, e);
+ }
+ },
+ [setShowDropdown, type, onClick, persist],
+ );
+
+ if (type === 'link') {
+ const {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ type: _,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ persist: __,
+ variant = 'dropdown-item',
+ ...otherProps
+ } = props;
+
+ return (
+
+ );
+ }
+
+ if (type === 'button') {
+ const {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ type: _,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ persist: __,
+ variant = 'dropdown-item',
+ ...otherProps
+ } = props;
+
+ return (
+
+ );
+ }
+
+ if (type === 'confirm-button') {
+ const {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ type: _,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ persist: __,
+ variant = 'dropdown-item',
+ ...otherProps
+ } = props;
+
+ return (
+
+ );
+ }
+}
+
+export default DropdownMenuItem;
diff --git a/src/components/FourHundredThree/i18n.json b/app/src/components/FourHundredThree/i18n.json
similarity index 100%
rename from src/components/FourHundredThree/i18n.json
rename to app/src/components/FourHundredThree/i18n.json
diff --git a/app/src/components/FourHundredThree/index.tsx b/app/src/components/FourHundredThree/index.tsx
new file mode 100644
index 000000000..b528c1290
--- /dev/null
+++ b/app/src/components/FourHundredThree/index.tsx
@@ -0,0 +1,63 @@
+import { SearchLineIcon } from '@ifrc-go/icons';
+import { Heading } from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import Link from '#components/Link';
+import Page from '#components/Page';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+function FourHundredThree() {
+ const strings = useTranslation(i18n);
+
+ return (
+
+
+
+
+
+
+ {strings.permissionHeadingLabel}
+
+
+ {strings.permissionDeniedHeading}
+
+
+ {strings.permissionDeniedPageDescription}
+
+
+
+ {strings.permissionDeniedAreYouSureUrlIsCorrect}
+
+
+ {strings.permissionDeniedGetInTouch}
+
+
+ {strings.permissionDeniedWithThePlatformTeam}
+
+
+ {strings.permissionDeniedExploreOurHomepage}
+
+
+
+ );
+}
+
+export default FourHundredThree;
diff --git a/src/components/FourHundredThree/styles.module.css b/app/src/components/FourHundredThree/styles.module.css
similarity index 100%
rename from src/components/FourHundredThree/styles.module.css
rename to app/src/components/FourHundredThree/styles.module.css
diff --git a/src/components/GlobalFooter/i18n.json b/app/src/components/GlobalFooter/i18n.json
similarity index 100%
rename from src/components/GlobalFooter/i18n.json
rename to app/src/components/GlobalFooter/i18n.json
diff --git a/app/src/components/GlobalFooter/index.tsx b/app/src/components/GlobalFooter/index.tsx
new file mode 100644
index 000000000..4b6ccf430
--- /dev/null
+++ b/app/src/components/GlobalFooter/index.tsx
@@ -0,0 +1,163 @@
+import {
+ SocialFacebookIcon,
+ SocialMediumIcon,
+ SocialTwitterIcon,
+ SocialYoutubeIcon,
+} from '@ifrc-go/icons';
+import {
+ Heading,
+ PageContainer,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { resolveToComponent } from '@ifrc-go/ui/utils';
+import { _cs } from '@togglecorp/fujs';
+
+import Link from '#components/Link';
+import {
+ api,
+ appCommitHash,
+ appVersion,
+} from '#config';
+import { resolveUrl } from '#utils/resolveUrl';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+const date = new Date();
+const year = date.getFullYear();
+
+interface Props {
+ className?: string;
+}
+
+function GlobalFooter(props: Props) {
+ const {
+ className,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const copyrightText = resolveToComponent(
+ strings.footerIFRC,
+ {
+ year,
+ appVersion: (
+
+ {appVersion}
+
+ ),
+ },
+ );
+
+ return (
+
+
+
+ {strings.footerAboutGo}
+
+
+ {strings.footerAboutGoDesc}
+
+
+ {copyrightText}
+
+
+
+
+ {strings.globalFindOut}
+
+
+
+ ifrc.org
+
+
+ rcrcsims.org
+
+
+ data.ifrc.org
+
+
+
+
+
+ {strings.globalHelpfulLinks}
+
+
+
+ {strings.footerOpenSourceCode}
+
+
+ {strings.footerApiDocumentation}
+
+
+ {strings.footerOtherResources}
+
+
+
+
+
+ {strings.footerContactUs}
+
+
+ im@ifrc.org
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default GlobalFooter;
diff --git a/src/components/GlobalFooter/styles.module.css b/app/src/components/GlobalFooter/styles.module.css
similarity index 100%
rename from src/components/GlobalFooter/styles.module.css
rename to app/src/components/GlobalFooter/styles.module.css
diff --git a/app/src/components/Link/index.tsx b/app/src/components/Link/index.tsx
new file mode 100644
index 000000000..373c3ef38
--- /dev/null
+++ b/app/src/components/Link/index.tsx
@@ -0,0 +1,293 @@
+import {
+ useContext,
+ useMemo,
+} from 'react';
+import {
+ generatePath,
+ Link as InternalLink,
+ LinkProps as RouterLinkProps,
+} from 'react-router-dom';
+import {
+ ChevronRightLineIcon,
+ ExternalLinkLineIcon,
+} from '@ifrc-go/icons';
+import type { ButtonFeatureProps } from '@ifrc-go/ui';
+import { useButtonFeatures } from '@ifrc-go/ui/hooks';
+import {
+ _cs,
+ isDefined,
+ isFalsyString,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import RouteContext from '#contexts/route';
+import useAuth from '#hooks/domain/useAuth';
+import usePermissions from '#hooks/domain/usePermissions';
+
+import { type WrappedRoutes } from '../../App/routes';
+
+import styles from './styles.module.css';
+
+export interface UrlParams {
+ [key: string]: string | number | null | undefined;
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function resolvePath(
+ to: keyof WrappedRoutes,
+ routes: WrappedRoutes,
+ urlParams: UrlParams | undefined,
+) {
+ const route = routes[to];
+ try {
+ const resolvedPath = generatePath(route.absoluteForwardPath, urlParams);
+ return {
+ ...route,
+ resolvedPath,
+ };
+ } catch (ex) {
+ return {
+ ...route,
+ resolvedPath: undefined,
+ };
+ }
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useLink(props: {
+ external: true,
+ href: string | undefined | null,
+ to?: never,
+ urlParams?: never,
+} | {
+ external: false | undefined,
+ to: keyof WrappedRoutes | undefined | null,
+ urlParams?: UrlParams,
+ href?: never,
+}) {
+ const { isAuthenticated } = useAuth();
+ const routes = useContext(RouteContext);
+ const perms = usePermissions();
+
+ if (props.external) {
+ if (isNotDefined(props.href)) {
+ return { disabled: true, to: undefined };
+ }
+ return { disabled: false, to: props.href };
+ }
+
+ if (isNotDefined(props.to)) {
+ return { disabled: true, to: undefined };
+ }
+
+ // eslint-disable-next-line react/destructuring-assignment
+ const route = resolvePath(props.to, routes, props.urlParams);
+ const { resolvedPath } = route;
+
+ if (isNotDefined(resolvedPath)) {
+ return { disabled: true, to: undefined };
+ }
+
+ const disabled = (route.visibility === 'is-authenticated' && !isAuthenticated)
+ || (route.visibility === 'is-not-authenticated' && isAuthenticated)
+ || (route.permissions && !route.permissions(perms, props.urlParams));
+
+ return {
+ disabled,
+ to: resolvedPath,
+ };
+}
+
+export type CommonLinkProps = Omit &
+Omit<{
+ actions?: React.ReactNode;
+ actionsContainerClassName?: string;
+ disabled?: boolean;
+ icons?: React.ReactNode;
+ iconsContainerClassName?: string;
+ linkElementClassName?: string;
+ // to?: RouterLinkProps['to'];
+ variant?: ButtonFeatureProps['variant'];
+ withLinkIcon?: boolean;
+ withUnderline?: boolean;
+ ellipsize?: boolean;
+ spacing?: ButtonFeatureProps['spacing'];
+}, OMISSION>
+
+export type InternalLinkProps = {
+ external?: never;
+ to: keyof WrappedRoutes | undefined | null;
+ urlParams?: UrlParams;
+ urlSearch?: string;
+ urlHash?: string;
+ href?: never;
+}
+
+export type ExternalLinkProps = {
+ external: true;
+ href: string | undefined | null;
+ urlParams?: never;
+ urlSearch?: never;
+ urlHash?: never;
+ to?: never;
+}
+
+export type Props = CommonLinkProps
+ & (InternalLinkProps | ExternalLinkProps)
+
+function Link(props: Props) {
+ const {
+ actions,
+ actionsContainerClassName,
+ children: childrenFromProps,
+ className,
+ disabled: disabledFromProps,
+ icons,
+ iconsContainerClassName,
+ linkElementClassName,
+ withUnderline,
+ withLinkIcon,
+ variant = 'tertiary',
+ ellipsize,
+ spacing,
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ external,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ to,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ urlParams,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ urlSearch,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ urlHash,
+ ...otherProps
+ } = props;
+
+ const {
+ disabled: disabledLink,
+ to: toLink,
+ } = useLink(
+ // eslint-disable-next-line react/destructuring-assignment
+ props.external
+ // eslint-disable-next-line react/destructuring-assignment
+ ? { href: props.href, external: true }
+ // eslint-disable-next-line react/destructuring-assignment
+ : { to: props.to, external: false, urlParams: props.urlParams },
+ );
+
+ // eslint-disable-next-line react/destructuring-assignment
+ const disabled = disabledFromProps || disabledLink;
+
+ // eslint-disable-next-line react/destructuring-assignment
+ const nonLink = isFalsyString(toLink);
+
+ const {
+ children: content,
+ className: containerClassName,
+ } = useButtonFeatures({
+ className: styles.content,
+ icons,
+ children: childrenFromProps,
+ variant,
+ ellipsize,
+ disabled,
+ spacing,
+ actions: (isDefined(actions) || withLinkIcon) ? (
+ <>
+ {actions}
+ {withLinkIcon && external && (
+
+ )}
+ {withLinkIcon && !external && (
+
+ )}
+ >
+ ) : null,
+ iconsContainerClassName,
+ actionsContainerClassName,
+ });
+
+ const children = useMemo(
+ () => {
+ if (isNotDefined(toLink)) {
+ return (
+
+ {content}
+
+ );
+ }
+ // eslint-disable-next-line react/destructuring-assignment
+ if (props.external) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ },
+ [
+ linkElementClassName,
+ containerClassName,
+ content,
+ otherProps,
+ toLink,
+ // eslint-disable-next-line react/destructuring-assignment
+ props.urlSearch,
+ // eslint-disable-next-line react/destructuring-assignment
+ props.urlHash,
+ // eslint-disable-next-line react/destructuring-assignment
+ props.external,
+ ],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+export default Link;
diff --git a/src/components/Link/styles.module.css b/app/src/components/Link/styles.module.css
similarity index 100%
rename from src/components/Link/styles.module.css
rename to app/src/components/Link/styles.module.css
diff --git a/src/components/MapContainerWithDisclaimer/i18n.json b/app/src/components/MapContainerWithDisclaimer/i18n.json
similarity index 100%
rename from src/components/MapContainerWithDisclaimer/i18n.json
rename to app/src/components/MapContainerWithDisclaimer/i18n.json
diff --git a/app/src/components/MapContainerWithDisclaimer/index.tsx b/app/src/components/MapContainerWithDisclaimer/index.tsx
new file mode 100644
index 000000000..ff97dce32
--- /dev/null
+++ b/app/src/components/MapContainerWithDisclaimer/index.tsx
@@ -0,0 +1,230 @@
+import {
+ useCallback,
+ useRef,
+} from 'react';
+import {
+ CloseFillIcon,
+ DownloadTwoLineIcon,
+} from '@ifrc-go/icons';
+import {
+ Button,
+ DateOutput,
+ Header,
+ IconButton,
+ InfoPopup,
+ RawButton,
+} from '@ifrc-go/ui';
+import {
+ useBooleanState,
+ useTranslation,
+} from '@ifrc-go/ui/hooks';
+import { resolveToComponent } from '@ifrc-go/ui/utils';
+import { _cs } from '@togglecorp/fujs';
+import { MapContainer } from '@togglecorp/re-map';
+import FileSaver from 'file-saver';
+import { toPng } from 'html-to-image';
+
+import goLogo from '#assets/icons/go-logo-2020.svg';
+import Link from '#components/Link';
+import { mbtoken } from '#config';
+import useAlert from '#hooks/useAlert';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+interface Props {
+ className?: string;
+ title?: string;
+ footer?: React.ReactNode;
+ withoutDownloadButton?: boolean;
+}
+
+function MapContainerWithDisclaimer(props: Props) {
+ const {
+ className,
+ title = 'Map',
+ footer,
+ withoutDownloadButton = false,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const mapSources = resolveToComponent(
+ strings.mapSourcesLabel,
+ {
+ uncodsLink: (
+
+ {strings.mapSourceUNCODsLabel}
+
+ ),
+ },
+ );
+
+ const [
+ printMode,
+ {
+ setTrue: enterPrintMode,
+ setFalse: exitPrintMode,
+ },
+ ] = useBooleanState(false);
+
+ const containerRef = useRef(null);
+
+ const alert = useAlert();
+ const handleDownloadClick = useCallback(() => {
+ if (!containerRef?.current) {
+ alert.show(
+ strings.failureToDownloadMessage,
+ { variant: 'danger' },
+ );
+ exitPrintMode();
+ return;
+ }
+ toPng(containerRef.current, {
+ skipAutoScale: false,
+ })
+ .then((data) => FileSaver.saveAs(data, title))
+ .finally(exitPrintMode);
+ }, [
+ exitPrintMode,
+ title,
+ alert,
+ strings.failureToDownloadMessage,
+ ]);
+
+ return (
+
+ {printMode && (
+
+
+ )}
+ >
+ {strings.downloadButtonTitle}
+
+
+
+
+ >
+ )}
+ />
+ )}
+
+ {printMode && (
+
+ {title}
+
+ >
+ )}
+ actions={(
+
+ )}
+ />
+ )}
+
+
+
+
+ {strings.mapDisclaimer}
+
+
+ {mapSources}
+
+
+
+ {strings.copyrightMapbox}
+
+
+ {strings.copyrightOSM}
+
+
+ {strings.improveMapLabel}
+
+
+
+ )}
+ />
+
+ {!printMode && !withoutDownloadButton && (
+
+
+
+ )}
+ {footer}
+
+
+ );
+}
+
+export default MapContainerWithDisclaimer;
diff --git a/src/components/MapContainerWithDisclaimer/styles.module.css b/app/src/components/MapContainerWithDisclaimer/styles.module.css
similarity index 100%
rename from src/components/MapContainerWithDisclaimer/styles.module.css
rename to app/src/components/MapContainerWithDisclaimer/styles.module.css
diff --git a/src/components/MapPopup/i18n.json b/app/src/components/MapPopup/i18n.json
similarity index 100%
rename from src/components/MapPopup/i18n.json
rename to app/src/components/MapPopup/i18n.json
diff --git a/app/src/components/MapPopup/index.tsx b/app/src/components/MapPopup/index.tsx
new file mode 100644
index 000000000..2961bb258
--- /dev/null
+++ b/app/src/components/MapPopup/index.tsx
@@ -0,0 +1,77 @@
+import { CloseLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ Container,
+ ContainerProps,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { _cs } from '@togglecorp/fujs';
+import { MapPopup as BasicMapPopup } from '@togglecorp/re-map';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+const popupOptions: mapboxgl.PopupOptions = {
+ closeButton: false,
+ closeOnClick: false,
+ closeOnMove: false,
+ offset: 8,
+ className: styles.mapPopup,
+ maxWidth: 'unset',
+};
+
+interface Props extends ContainerProps {
+ coordinates: mapboxgl.LngLatLike;
+ children: React.ReactNode;
+ onCloseButtonClick: () => void;
+}
+
+function MapPopup(props: Props) {
+ const {
+ children,
+ coordinates,
+ onCloseButtonClick,
+ actions,
+ childrenContainerClassName,
+ ...containerProps
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ return (
+
+
+ {actions}
+
+ >
+ )}
+ >
+ {children}
+
+
+ );
+}
+
+export default MapPopup;
diff --git a/src/components/MapPopup/styles.module.css b/app/src/components/MapPopup/styles.module.css
similarity index 100%
rename from src/components/MapPopup/styles.module.css
rename to app/src/components/MapPopup/styles.module.css
diff --git a/src/components/Navbar/AuthenticatedUserDropdown/i18n.json b/app/src/components/Navbar/AuthenticatedUserDropdown/i18n.json
similarity index 100%
rename from src/components/Navbar/AuthenticatedUserDropdown/i18n.json
rename to app/src/components/Navbar/AuthenticatedUserDropdown/i18n.json
diff --git a/app/src/components/Navbar/AuthenticatedUserDropdown/index.tsx b/app/src/components/Navbar/AuthenticatedUserDropdown/index.tsx
new file mode 100644
index 000000000..01932e17a
--- /dev/null
+++ b/app/src/components/Navbar/AuthenticatedUserDropdown/index.tsx
@@ -0,0 +1,72 @@
+import {
+ useCallback,
+ useContext,
+} from 'react';
+import { DropdownMenu } from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import DropdownMenuItem from '#components/DropdownMenuItem';
+import UserContext from '#contexts/user';
+import useAuth from '#hooks/domain/useAuth';
+import useUserMe from '#hooks/domain/useUserMe';
+import { getUserName } from '#utils/domain/user';
+
+import i18n from './i18n.json';
+
+interface Props {
+ className?: string;
+}
+
+function AuthenticatedUserDropdown(props: Props) {
+ const {
+ className,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const { isAuthenticated } = useAuth();
+
+ const {
+ userAuth: userDetails,
+ removeUserAuth: removeUser,
+ } = useContext(UserContext);
+ const userMe = useUserMe();
+
+ const handleLogoutConfirm = useCallback(() => {
+ removeUser();
+ window.location.reload();
+ }, [removeUser]);
+
+ if (!isAuthenticated) {
+ return null;
+ }
+
+ return (
+
+
+ {strings.userMenuAccount}
+
+
+ {strings.userMenuLogout}
+
+
+ );
+}
+
+export default AuthenticatedUserDropdown;
diff --git a/src/components/Navbar/CountryDropdown/i18n.json b/app/src/components/Navbar/CountryDropdown/i18n.json
similarity index 100%
rename from src/components/Navbar/CountryDropdown/i18n.json
rename to app/src/components/Navbar/CountryDropdown/i18n.json
diff --git a/app/src/components/Navbar/CountryDropdown/index.tsx b/app/src/components/Navbar/CountryDropdown/index.tsx
new file mode 100644
index 000000000..70ee7fa28
--- /dev/null
+++ b/app/src/components/Navbar/CountryDropdown/index.tsx
@@ -0,0 +1,201 @@
+import {
+ useContext,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ matchPath,
+ useLocation,
+} from 'react-router-dom';
+import { SearchLineIcon } from '@ifrc-go/icons';
+import {
+ Container,
+ DropdownMenu,
+ Message,
+ Tab,
+ TabList,
+ TabPanel,
+ Tabs,
+ TextInput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { rankedSearchOnList } from '@ifrc-go/ui/utils';
+import {
+ _cs,
+ isDefined,
+ isFalsyString,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import DropdownMenuItem from '#components/DropdownMenuItem';
+import RouteContext from '#contexts/route';
+import useCountry from '#hooks/domain/useCountry';
+import useGlobalEnums from '#hooks/domain/useGlobalEnums';
+import useInputState from '#hooks/useInputState';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+function CountryDropdown() {
+ const location = useLocation();
+
+ const strings = useTranslation(i18n);
+ const {
+ regionsLayout: regionRoute,
+ } = useContext(RouteContext);
+ const {
+ api_region_name: regionOptions,
+ } = useGlobalEnums();
+ type RegionKey = NonNullable<(typeof regionOptions)>[number]['key'];
+
+ const match = matchPath(
+ {
+ path: regionRoute.absolutePath,
+ end: false,
+ },
+ location.pathname,
+ );
+
+ const regionIdFromMatch = useMemo(
+ () => {
+ const regionId = match?.params?.regionId;
+ if (isFalsyString(regionId)) {
+ return undefined;
+ }
+
+ const regionIdSafe = Number(regionId);
+
+ if (
+ regionIdSafe !== 0
+ && regionIdSafe !== 1
+ && regionIdSafe !== 2
+ && regionIdSafe !== 3
+ && regionIdSafe !== 4
+ ) {
+ return undefined;
+ }
+
+ return regionIdSafe;
+ },
+ [match],
+ );
+
+ const isEmpty = isNotDefined(regionOptions) || regionOptions.length === 0;
+
+ const [activeRegion, setActiveRegion] = useState(regionIdFromMatch ?? 0);
+ const [countrySearch, setCountrySearch] = useInputState(undefined);
+
+ const allCountries = useCountry();
+ const countriesInSelectedRegion = useMemo(
+ () => (
+ rankedSearchOnList(
+ allCountries?.filter(({ region }) => region === activeRegion),
+ countrySearch,
+ ({ name }) => name,
+ )
+ ),
+ [activeRegion, allCountries, countrySearch],
+ );
+
+ return (
+
+ {isEmpty && (
+
+ )}
+ {!isEmpty && (
+
+
+ {regionOptions?.map(
+ (region) => (
+
+ {region.value}
+
+ ),
+ )}
+
+
+ {regionOptions?.map(
+ (region) => (
+
+
+ {/* FIXME: use translation */}
+ {`${region.value} Region`}
+
+ )}
+ headingLevel={4}
+ childrenContainerClassName={styles.countryList}
+ actions={(
+ }
+ />
+ )}
+ >
+ {/* TODO: use List */}
+ {countriesInSelectedRegion?.map(
+ ({ id, name }) => (
+
+ {name}
+
+ ),
+ )}
+
+
+ ),
+ )}
+
+ )}
+
+ );
+}
+
+export default CountryDropdown;
diff --git a/src/components/Navbar/CountryDropdown/styles.module.css b/app/src/components/Navbar/CountryDropdown/styles.module.css
similarity index 100%
rename from src/components/Navbar/CountryDropdown/styles.module.css
rename to app/src/components/Navbar/CountryDropdown/styles.module.css
diff --git a/app/src/components/Navbar/LanguageDropdown/index.tsx b/app/src/components/Navbar/LanguageDropdown/index.tsx
new file mode 100644
index 000000000..29ac0e8c1
--- /dev/null
+++ b/app/src/components/Navbar/LanguageDropdown/index.tsx
@@ -0,0 +1,93 @@
+import {
+ useCallback,
+ useContext,
+ useEffect,
+} from 'react';
+import { CheckFillIcon } from '@ifrc-go/icons';
+import { DropdownMenu } from '@ifrc-go/ui';
+import {
+ type Language,
+ LanguageContext,
+} from '@ifrc-go/ui/contexts';
+import { languageNameMapEn } from '@ifrc-go/ui/utils';
+import {
+ _cs,
+ mapToList,
+} from '@togglecorp/fujs';
+
+import DropdownMenuItem from '#components/DropdownMenuItem';
+
+import styles from './styles.module.css';
+
+// NOTE: these doesn't need to be translated
+const languageNameMap: Record = {
+ en: 'English',
+ fr: 'Français',
+ es: 'Español',
+ ar: 'عربي',
+};
+
+const languageList = mapToList(
+ languageNameMap,
+ (value, key) => ({ key: key as Language, value }),
+);
+
+function LangaugeDropdown() {
+ const {
+ currentLanguage,
+ setCurrentLanguage,
+ } = useContext(LanguageContext);
+
+ useEffect(
+ () => {
+ if (currentLanguage === 'ar') {
+ document.body.style.direction = 'rtl';
+ document.body.setAttribute('dir', 'rtl');
+ } else {
+ document.body.style.direction = 'ltr';
+ document.body.setAttribute('dir', 'ltr');
+ }
+ },
+ [currentLanguage],
+ );
+
+ const handleLanguageConfirm = useCallback(
+ (newLanguage: Language) => {
+ setCurrentLanguage(newLanguage);
+ window.location.reload();
+ },
+ [setCurrentLanguage],
+ );
+
+ return (
+
+ {languageList.map(
+ (language) => (
+
+ )}
+ >
+ {language.value}
+
+ ),
+ )}
+
+ );
+}
+
+export default LangaugeDropdown;
diff --git a/src/components/Navbar/LanguageDropdown/styles.module.css b/app/src/components/Navbar/LanguageDropdown/styles.module.css
similarity index 100%
rename from src/components/Navbar/LanguageDropdown/styles.module.css
rename to app/src/components/Navbar/LanguageDropdown/styles.module.css
diff --git a/src/components/Navbar/i18n.json b/app/src/components/Navbar/i18n.json
similarity index 100%
rename from src/components/Navbar/i18n.json
rename to app/src/components/Navbar/i18n.json
diff --git a/app/src/components/Navbar/index.tsx b/app/src/components/Navbar/index.tsx
new file mode 100644
index 000000000..550f34e77
--- /dev/null
+++ b/app/src/components/Navbar/index.tsx
@@ -0,0 +1,505 @@
+import { useState } from 'react';
+import {
+ DropdownMenu,
+ NavigationTabList,
+ PageContainer,
+ Tab,
+ TabList,
+ TabPanel,
+ Tabs,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { _cs } from '@togglecorp/fujs';
+
+import goLogo from '#assets/icons/go-logo-2020.svg';
+import KeywordSearchSelectInput from '#components/domain/KeywordSearchSelectInput';
+import DropdownMenuItem from '#components/DropdownMenuItem';
+import Link from '#components/Link';
+import NavigationTab from '#components/NavigationTab';
+import { environment } from '#config';
+import useAuth from '#hooks/domain/useAuth';
+
+import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
+import CountryDropdown from './CountryDropdown';
+import LangaugeDropdown from './LanguageDropdown';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+interface Props {
+ className?: string;
+}
+
+function Navbar(props: Props) {
+ const {
+ className,
+ } = props;
+
+ const { isAuthenticated } = useAuth();
+ const strings = useTranslation(i18n);
+
+ type PrepareOptionKey = 'risk-analysis' | 'per' | 'global-3w-projects';
+ const [activePrepareOption, setActivePrepareOption] = useState('risk-analysis');
+
+ type RespondOptionKey = 'emergencies' | 'early-warning' | 'dref-process' | 'surge';
+ const [activeRespondOption, setActiveRespondOption] = useState('emergencies');
+
+ type LearnOptionKey = 'tools' | 'resources';
+ const [activeLearnOption, setActiveLearnOption] = useState('tools');
+
+ return (
+
+ );
+}
+
+export default Navbar;
diff --git a/src/components/Navbar/styles.module.css b/app/src/components/Navbar/styles.module.css
similarity index 100%
rename from src/components/Navbar/styles.module.css
rename to app/src/components/Navbar/styles.module.css
diff --git a/app/src/components/NavigationTab/index.tsx b/app/src/components/NavigationTab/index.tsx
new file mode 100644
index 000000000..e1c72e8e3
--- /dev/null
+++ b/app/src/components/NavigationTab/index.tsx
@@ -0,0 +1,199 @@
+import {
+ useCallback,
+ useContext,
+ useMemo,
+} from 'react';
+import {
+ matchPath,
+ NavLink,
+ useLocation,
+} from 'react-router-dom';
+import { CheckFillIcon } from '@ifrc-go/icons';
+import { NavigationTabContext } from '@ifrc-go/ui/contexts';
+import {
+ _cs,
+ isFalsyString,
+ isNotDefined,
+ isTruthyString,
+} from '@togglecorp/fujs';
+
+import {
+ type UrlParams,
+ useLink,
+} from '#components/Link';
+import RouteContext from '#contexts/route';
+
+import { type WrappedRoutes } from '../../App/routes';
+
+import styles from './styles.module.css';
+
+interface Props {
+ className?: string;
+ children?: React.ReactNode;
+ stepCompleted?: boolean;
+ title?: string;
+ disabled?: boolean;
+
+ parentRoute?: boolean;
+ matchParam?: string;
+
+ to: keyof WrappedRoutes | undefined | null;
+
+ urlParams?: UrlParams;
+ urlSearch?: string;
+ urlHash?: string;
+}
+
+function NavigationTab(props: Props) {
+ const {
+ children,
+ to,
+ urlParams,
+ urlSearch,
+ urlHash,
+ className,
+ title,
+ stepCompleted,
+ disabled: disabledFromProps,
+ parentRoute = false,
+ matchParam,
+ } = props;
+
+ const {
+ variant,
+ className: classNameFromContext,
+ } = useContext(NavigationTabContext);
+
+ const location = useLocation();
+
+ const routes = useContext(RouteContext);
+
+ const {
+ disabled: disabledLink,
+ to: toLink,
+ } = useLink({
+ to,
+ external: false,
+ urlParams,
+ });
+
+ const disabled = disabledLink || disabledFromProps;
+
+ const handleClick = useCallback((
+ event: React.MouseEvent | undefined,
+ ) => {
+ if (disabled) {
+ event?.preventDefault();
+ }
+ }, [disabled]);
+
+ const matchParamValue = isTruthyString(matchParam)
+ ? urlParams?.[matchParam]
+ : undefined;
+
+ const isActive = useMemo(
+ () => {
+ if (isNotDefined(to)) {
+ return false;
+ }
+
+ const match = matchPath(
+ {
+ // eslint-disable-next-line react/destructuring-assignment
+ path: routes[to].absolutePath,
+ end: !parentRoute,
+ },
+ location.pathname,
+ );
+
+ if (isNotDefined(match)) {
+ return false;
+ }
+
+ if (isTruthyString(matchParam)) {
+ const paramValue = match.params[matchParam];
+
+ if (isFalsyString(paramValue)) {
+ return false;
+ }
+
+ return matchParamValue === paramValue;
+ }
+
+ return true;
+ },
+ [to, routes, location.pathname, matchParam, parentRoute, matchParamValue],
+ );
+
+ const linkClassName = useMemo(
+ () => {
+ const defaultClassName = _cs(
+ styles.navigationTab,
+ variant === 'primary' && styles.primary,
+ variant === 'secondary' && styles.secondary,
+ variant === 'tertiary' && styles.tertiary,
+ variant === 'step' && styles.step,
+ variant === 'vertical' && styles.vertical,
+ stepCompleted && styles.completed,
+ classNameFromContext,
+ className,
+ disabled && styles.disabled,
+ );
+
+ if (!isActive) {
+ return defaultClassName;
+ }
+
+ return _cs(
+ styles.active,
+ defaultClassName,
+ );
+ },
+ [
+ className,
+ classNameFromContext,
+ stepCompleted,
+ variant,
+ isActive,
+ disabled,
+ ],
+ );
+
+ return (
+
+ {variant === 'step' && (
+
+
+
+
+ {stepCompleted && (
+
+ )}
+
+
+
+
+ )}
+ {variant === 'primary' && (
+
+ )}
+
+ {children}
+
+ {variant === 'primary' && (
+
+ )}
+
+ );
+}
+
+export default NavigationTab;
diff --git a/src/components/NavigationTab/styles.module.css b/app/src/components/NavigationTab/styles.module.css
similarity index 100%
rename from src/components/NavigationTab/styles.module.css
rename to app/src/components/NavigationTab/styles.module.css
diff --git a/src/components/NonFieldError/i18n.json b/app/src/components/NonFieldError/i18n.json
similarity index 100%
rename from src/components/NonFieldError/i18n.json
rename to app/src/components/NonFieldError/i18n.json
diff --git a/app/src/components/NonFieldError/index.tsx b/app/src/components/NonFieldError/index.tsx
new file mode 100644
index 000000000..8602c6932
--- /dev/null
+++ b/app/src/components/NonFieldError/index.tsx
@@ -0,0 +1,65 @@
+import { useMemo } from 'react';
+import { AlertLineIcon } from '@ifrc-go/icons';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ _cs,
+ isFalsyString,
+ isNotDefined,
+} from '@togglecorp/fujs';
+import {
+ analyzeErrors,
+ Error,
+ getErrorObject,
+ nonFieldError,
+} from '@togglecorp/toggle-form';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+export interface Props {
+ className?: string;
+ error?: Error;
+ withFallbackError?: boolean;
+}
+
+function NonFieldError(props: Props) {
+ const {
+ className,
+ error,
+ withFallbackError,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const errorObject = useMemo(() => getErrorObject(error), [error]);
+
+ if (isNotDefined(errorObject)) {
+ return null;
+ }
+
+ const hasError = analyzeErrors(errorObject);
+ if (!hasError) {
+ return null;
+ }
+
+ const stringError = errorObject?.[nonFieldError] || (
+ withFallbackError ? strings.fallbackMessage : undefined);
+
+ if (isFalsyString(stringError)) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export default NonFieldError;
diff --git a/src/components/NonFieldError/styles.module.css b/app/src/components/NonFieldError/styles.module.css
similarity index 100%
rename from src/components/NonFieldError/styles.module.css
rename to app/src/components/NonFieldError/styles.module.css
diff --git a/src/components/Page/i18n.json b/app/src/components/Page/i18n.json
similarity index 100%
rename from src/components/Page/i18n.json
rename to app/src/components/Page/i18n.json
diff --git a/app/src/components/Page/index.tsx b/app/src/components/Page/index.tsx
new file mode 100644
index 000000000..377c653ec
--- /dev/null
+++ b/app/src/components/Page/index.tsx
@@ -0,0 +1,151 @@
+import {
+ ElementRef,
+ RefObject,
+ useEffect,
+} from 'react';
+import {
+ PageContainer,
+ PageHeader,
+} from '@ifrc-go/ui';
+import { type Language } from '@ifrc-go/ui/contexts';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ languageNameMapEn,
+ resolveToString,
+} from '@ifrc-go/ui/utils';
+import {
+ _cs,
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import { components } from '#generated/types';
+import useCurrentLanguage from '#hooks/domain/useCurrentLanguage';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type TranslationModuleOriginalLanguageEnum = components<'read'>['schemas']['TranslationModuleOriginalLanguageEnum'];
+
+interface Props {
+ className?: string;
+ title?: string;
+ actions?: React.ReactNode;
+ heading?: React.ReactNode;
+ description?: React.ReactNode;
+ descriptionContainerClassName?: string;
+ mainSectionContainerClassName?: string;
+ breadCrumbs?: React.ReactNode;
+ info?: React.ReactNode;
+ children?: React.ReactNode;
+ mainSectionClassName?: string;
+ infoContainerClassName?: string;
+ wikiLink?: React.ReactNode;
+ withBackgroundColorInMainSection?: boolean;
+ elementRef?: RefObject>;
+ blockingContent?: React.ReactNode;
+ contentOriginalLanguage?: TranslationModuleOriginalLanguageEnum;
+ beforeHeaderContent?: React.ReactNode;
+}
+
+function Page(props: Props) {
+ const {
+ className,
+ title,
+ actions,
+ heading,
+ description,
+ descriptionContainerClassName,
+ breadCrumbs,
+ info,
+ children,
+ mainSectionContainerClassName,
+ mainSectionClassName,
+ infoContainerClassName,
+ wikiLink,
+ withBackgroundColorInMainSection,
+ elementRef,
+ blockingContent,
+ contentOriginalLanguage,
+ beforeHeaderContent,
+ } = props;
+
+ const currentLanguage = useCurrentLanguage();
+ const strings = useTranslation(i18n);
+
+ useEffect(() => {
+ if (isDefined(title)) {
+ document.title = title;
+ }
+ }, [title]);
+
+ const showMachineTranslationWarning = isDefined(contentOriginalLanguage)
+ && contentOriginalLanguage !== currentLanguage;
+
+ const showPageContainer = !!breadCrumbs
+ || !!heading
+ || !!description
+ || !!info
+ || !!actions
+ || !!wikiLink;
+
+ return (
+
+ {isNotDefined(blockingContent)
+ && showMachineTranslationWarning
+ && (
+
+ {resolveToString(
+ strings.machineTranslatedContentWarning,
+ // eslint-disable-next-line max-len
+ { contentOriginalLanguage: languageNameMapEn[contentOriginalLanguage as Language] },
+ )}
+
+ )}
+ {beforeHeaderContent && (
+
+ {beforeHeaderContent}
+
+ )}
+ {isNotDefined(blockingContent) && showPageContainer && (
+
+ )}
+ {isNotDefined(blockingContent) && (
+
+ { children }
+
+ )}
+
+ );
+}
+
+export default Page;
diff --git a/src/components/Page/styles.module.css b/app/src/components/Page/styles.module.css
similarity index 100%
rename from src/components/Page/styles.module.css
rename to app/src/components/Page/styles.module.css
diff --git a/app/src/components/RichTextArea/index.tsx b/app/src/components/RichTextArea/index.tsx
new file mode 100644
index 000000000..8ed2e84cd
--- /dev/null
+++ b/app/src/components/RichTextArea/index.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import {
+ InputContainer,
+ InputContainerProps,
+} from '@ifrc-go/ui';
+import {
+ Editor,
+ IAllProps,
+} from '@tinymce/tinymce-react';
+import { _cs } from '@togglecorp/fujs';
+
+import { tinyApiKey } from '#config';
+
+import styles from './styles.module.css';
+
+type RawEditorOptions = NonNullable;
+
+const editorOptions: Omit = {
+ menubar: false, // https://www.tiny.cloud/docs/advanced/available-toolbar-buttons
+ statusbar: false,
+ plugins: ['advlist autolink code help link lists preview'],
+ toolbar: 'formatselect | bold italic superscript link | '
+ + 'alignleft aligncenter alignright alignjustify | '
+ + 'bullist numlist outdent indent | code removeformat preview | help',
+ contextmenu: 'formats link',
+ // https://www.tiny.cloud/docs/configure/content-filtering/#invalid_styles
+ invalid_styles: { '*': 'opacity' },
+};
+
+type InheritedProps = Omit & {
+ value: string | undefined;
+ name: T;
+ onChange?: (
+ value: string | undefined,
+ name: T,
+ ) => void;
+}
+export interface Props extends InheritedProps {
+ inputElementRef?: React.RefObject;
+ inputClassName?: string;
+ placeholder?: string;
+}
+
+function RichTextArea(props: Props) {
+ const {
+ className,
+ actions,
+ icons,
+ error,
+ label,
+ labelClassName,
+ disabled,
+ readOnly,
+ name,
+ value,
+ onChange,
+ inputSectionClassName,
+ hint,
+ withAsterisk,
+ errorOnTooltip,
+ required,
+ variant,
+ ...otherInputProps
+ } = props;
+
+ const handleChange = React.useCallback((newValue: string | undefined) => {
+ if (readOnly || disabled || !onChange) {
+ return;
+ }
+ if (newValue === '') {
+ onChange(undefined, name);
+ } else {
+ onChange(newValue, name);
+ }
+ }, [
+ onChange,
+ name,
+ readOnly,
+ disabled,
+ ]);
+
+ // eslint-disable-next-line react/destructuring-assignment
+ if (props.placeholder !== undefined) {
+ // eslint-disable-next-line react/destructuring-assignment
+ editorOptions.placeholder = props.placeholder;
+ }
+
+ return (
+
+ )}
+ />
+ );
+}
+
+export default RichTextArea;
diff --git a/src/components/RichTextArea/styles.module.css b/app/src/components/RichTextArea/styles.module.css
similarity index 100%
rename from src/components/RichTextArea/styles.module.css
rename to app/src/components/RichTextArea/styles.module.css
diff --git a/src/components/WikiLink/i18n.json b/app/src/components/WikiLink/i18n.json
similarity index 100%
rename from src/components/WikiLink/i18n.json
rename to app/src/components/WikiLink/i18n.json
diff --git a/app/src/components/WikiLink/index.tsx b/app/src/components/WikiLink/index.tsx
new file mode 100644
index 000000000..e3def2c4f
--- /dev/null
+++ b/app/src/components/WikiLink/index.tsx
@@ -0,0 +1,44 @@
+import { WikiHelpSectionLineIcon } from '@ifrc-go/icons';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { _cs } from '@togglecorp/fujs';
+
+import type { Props as LinkProps } from '#components/Link';
+import Link from '#components/Link';
+import useCurrentLanguage from '#hooks/domain/useCurrentLanguage';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type Props = Omit & {
+ icons?: React.ReactNode;
+ href: string | undefined | null;
+ className?: string;
+}
+
+function WikiLink(props: Props) {
+ const {
+ icons,
+ href,
+ className,
+ ...otherProps
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const lang = useCurrentLanguage();
+
+ return (
+
+
+
+ );
+}
+
+export default WikiLink;
diff --git a/src/components/WikiLink/styles.module.css b/app/src/components/WikiLink/styles.module.css
similarity index 100%
rename from src/components/WikiLink/styles.module.css
rename to app/src/components/WikiLink/styles.module.css
diff --git a/src/components/domain/ActiveOperationMap/i18n.json b/app/src/components/domain/ActiveOperationMap/i18n.json
similarity index 100%
rename from src/components/domain/ActiveOperationMap/i18n.json
rename to app/src/components/domain/ActiveOperationMap/i18n.json
diff --git a/app/src/components/domain/ActiveOperationMap/index.tsx b/app/src/components/domain/ActiveOperationMap/index.tsx
new file mode 100644
index 000000000..f334bd6f9
--- /dev/null
+++ b/app/src/components/domain/ActiveOperationMap/index.tsx
@@ -0,0 +1,581 @@
+import {
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import { ArtboardLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ Container,
+ DateInput,
+ LegendItem,
+ RadioInput,
+ SelectInput,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ hasSomeDefinedValue,
+ resolveToComponent,
+ sumSafe,
+} from '@ifrc-go/ui/utils';
+import {
+ _cs,
+ isDefined,
+ isNotDefined,
+ listToGroupList,
+ mapToMap,
+ unique,
+} from '@togglecorp/fujs';
+import {
+ MapBounds,
+ MapLayer,
+ MapSource,
+} from '@togglecorp/re-map';
+import type { LngLatBoundsLike } from 'mapbox-gl';
+
+import BaseMap from '#components/domain/BaseMap';
+import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput';
+import DistrictSearchMultiSelectInput, { type DistrictItem } from '#components/domain/DistrictSearchMultiSelectInput';
+import Link from '#components/Link';
+import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer';
+import MapPopup from '#components/MapPopup';
+import useCountryRaw from '#hooks/domain/useCountryRaw';
+import useGlobalEnums from '#hooks/domain/useGlobalEnums';
+import useFilterState from '#hooks/useFilterState';
+import useInputState from '#hooks/useInputState';
+import {
+ DEFAULT_MAP_PADDING,
+ DURATION_MAP_ZOOM,
+} from '#utils/constants';
+import { adminFillLayerOptions } from '#utils/map';
+import type {
+ GoApiResponse,
+ GoApiUrlQuery,
+} from '#utils/restRequest';
+import { useRequest } from '#utils/restRequest';
+
+import {
+ APPEAL_TYPE_DREF,
+ APPEAL_TYPE_EAP,
+ APPEAL_TYPE_EMERGENCY,
+ APPEAL_TYPE_MULTIPLE,
+ basePointLayerOptions,
+ COLOR_DREF,
+ COLOR_EAP,
+ COLOR_EMERGENCY_APPEAL,
+ COLOR_MULTIPLE_TYPES,
+ optionKeySelector,
+ optionLabelSelector,
+ outerCircleLayerOptionsForFinancialRequirements,
+ outerCircleLayerOptionsForPeopleTargeted,
+ ScaleOption,
+} from './utils';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type AppealQueryParams = GoApiUrlQuery<'/api/v2/appeal/'>;
+type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>;
+type AppealTypeOption = NonNullable[number];
+
+const appealTypeKeySelector = (option: AppealTypeOption) => option.key;
+const appealTypeLabelSelector = (option: AppealTypeOption) => option.value;
+
+const now = new Date().toISOString();
+const sourceOptions: mapboxgl.GeoJSONSourceRaw = {
+ type: 'geojson',
+};
+
+interface CountryProperties {
+ country_id: number;
+ disputed: boolean;
+ fdrs: string;
+ independent: boolean;
+ is_deprecated: boolean;
+ iso: string;
+ iso3: string;
+ name: string;
+ name_ar: string;
+ name_es: string;
+ name_fr: string;
+ record_type: number;
+ region_id: number;
+}
+
+interface ClickedPoint {
+ feature: GeoJSON.Feature;
+ lngLat: mapboxgl.LngLatLike;
+}
+
+type BaseProps = {
+ className?: string;
+ onPresentationModeButtonClick?: () => void;
+ presentationMode?: boolean;
+ bbox: LngLatBoundsLike | undefined;
+}
+
+type CountryProps = {
+ variant: 'country';
+ countryId: number;
+}
+type RegionProps = {
+ variant: 'region';
+ regionId: number;
+}
+
+type GlobalProps = {
+ variant: 'global';
+}
+
+type Props = BaseProps & (RegionProps | GlobalProps | CountryProps);
+
+function ActiveOperationMap(props: Props) {
+ const {
+ className,
+ variant,
+ onPresentationModeButtonClick,
+ presentationMode = false,
+ bbox,
+ } = props;
+
+ const {
+ filter,
+ filtered,
+ limit,
+ rawFilter,
+ setFilter,
+ setFilterField,
+ } = useFilterState<{
+ appeal?: AppealTypeOption['key'],
+ district?: number[],
+ displacement?: number,
+ startDateAfter?: string,
+ startDateBefore?: string,
+ }>({
+ filter: {},
+ pageSize: 9999,
+ });
+
+ // eslint-disable-next-line react/destructuring-assignment
+ const regionId = variant === 'region' ? props.regionId : undefined;
+ // eslint-disable-next-line react/destructuring-assignment
+ const countryId = variant === 'country' ? props.countryId : undefined;
+ // eslint-disable-next-line react/destructuring-assignment
+
+ const query = useMemo(
+ () => {
+ const baseQuery: AppealQueryParams = {
+ atype: filter.appeal,
+ dtype: filter.displacement,
+ district: hasSomeDefinedValue(filter.district) ? filter.district : undefined,
+ end_date__gt: now,
+ start_date__gte: filter.startDateAfter,
+ start_date__lte: filter.startDateBefore,
+ limit,
+ };
+
+ if (variant === 'global') {
+ return baseQuery;
+ }
+
+ return {
+ ...baseQuery,
+ region: regionId ? [regionId] : undefined,
+ country: countryId ? [countryId] : undefined,
+ };
+ },
+ [variant, regionId, filter, limit, countryId],
+ );
+
+ const [
+ clickedPointProperties,
+ setClickedPointProperties,
+ ] = useState();
+
+ const [scaleBy, setScaleBy] = useInputState('peopleTargeted');
+ const strings = useTranslation(i18n);
+ const { api_appeal_type: appealTypeOptions } = useGlobalEnums();
+ const {
+ response: appealResponse,
+ } = useRequest({
+ url: '/api/v2/appeal/',
+ query,
+ });
+
+ const countryResponse = useCountryRaw();
+
+ const scaleOptions: ScaleOption[] = useMemo(() => ([
+ { value: 'peopleTargeted', label: strings.explanationBubblePopulationLabel },
+ { value: 'financialRequirements', label: strings.explanationBubbleAmountLabel },
+ ]), [
+ strings.explanationBubblePopulationLabel,
+ strings.explanationBubbleAmountLabel,
+ ]);
+
+ const legendOptions = useMemo(() => ([
+ {
+ value: APPEAL_TYPE_EMERGENCY,
+ label: strings.explanationBubbleEmergencyAppeal,
+ color: COLOR_EMERGENCY_APPEAL,
+ },
+ {
+ value: APPEAL_TYPE_DREF,
+ label: strings.explanationBubbleDref,
+ color: COLOR_DREF,
+ },
+ {
+ value: APPEAL_TYPE_EAP,
+ label: strings.explanationBubbleEAP,
+ color: COLOR_EAP,
+ },
+ {
+ value: APPEAL_TYPE_MULTIPLE,
+ label: strings.explanationBubbleMultiple,
+ color: COLOR_MULTIPLE_TYPES,
+ },
+ ]), [
+ strings.explanationBubbleEmergencyAppeal,
+ strings.explanationBubbleDref,
+ strings.explanationBubbleEAP,
+ strings.explanationBubbleMultiple,
+ ]);
+
+ const countryGroupedAppeal = useMemo(() => (
+ listToGroupList(
+ appealResponse?.results ?? [],
+ (appeal) => appeal.country.iso3 ?? '',
+ )
+ ), [appealResponse]);
+
+ const countryCentroidGeoJson = useMemo(
+ (): GeoJSON.FeatureCollection => {
+ const countryToOperationTypeMap = mapToMap(
+ countryGroupedAppeal,
+ (key) => key,
+ (appealList) => {
+ const uniqueAppealList = unique(
+ appealList.map((appeal) => appeal.atype),
+ );
+
+ const peopleTargeted = sumSafe(
+ appealList.map((appeal) => appeal.num_beneficiaries),
+ );
+ const financialRequirements = sumSafe(
+ appealList.map((appeal) => appeal.amount_requested),
+ );
+
+ if (uniqueAppealList.length > 1) {
+ // multiple types
+ return {
+ appealType: APPEAL_TYPE_MULTIPLE,
+ peopleTargeted,
+ financialRequirements,
+ };
+ }
+
+ return {
+ appealType: uniqueAppealList[0],
+ peopleTargeted,
+ financialRequirements,
+ };
+ },
+ );
+
+ return {
+ type: 'FeatureCollection' as const,
+ features: countryResponse
+ ?.map((country) => {
+ if (
+ (!country.independent && isNotDefined(country.record_type))
+ || isNotDefined(country.centroid)
+ || isNotDefined(country.iso3)
+ ) {
+ return undefined;
+ }
+
+ const operation = countryToOperationTypeMap[country.iso3];
+ if (isNotDefined(operation)) {
+ return undefined;
+ }
+
+ return {
+ type: 'Feature' as const,
+ geometry: country.centroid as {
+ type: 'Point',
+ coordinates: [number, number],
+ },
+ properties: {
+ id: country.iso3,
+ appealType: operation.appealType,
+ peopleTargeted: operation.peopleTargeted,
+ financialRequirements: operation.financialRequirements,
+ },
+ };
+ }).filter(isDefined) ?? [],
+ };
+ },
+ [countryResponse, countryGroupedAppeal],
+ );
+
+ const allAppealsType = useMemo(() => {
+ if (isDefined(countryId)) {
+ return {
+ searchParam: `country=${countryId}`,
+ title: strings.operationMapViewAllInCountry,
+ };
+ }
+ if (isDefined(regionId)) {
+ return {
+ searchParam: `region=${regionId}`,
+ title: strings.operationMapViewAllInRegion,
+ };
+ }
+ return {
+ searchParam: undefined,
+ title: strings.operationMapViewAll,
+ };
+ }, [
+ countryId,
+ regionId,
+ strings.operationMapViewAllInCountry,
+ strings.operationMapViewAllInRegion,
+ strings.operationMapViewAll,
+ ]);
+
+ const heading = resolveToComponent(
+ strings.activeOperationsTitle,
+ { numAppeals: appealResponse?.count ?? '--' },
+ );
+
+ const handleCountryClick = useCallback((
+ feature: mapboxgl.MapboxGeoJSONFeature,
+ lngLat: mapboxgl.LngLatLike,
+ ) => {
+ setClickedPointProperties({
+ feature: feature as unknown as ClickedPoint['feature'],
+ lngLat,
+ });
+ return false;
+ }, []);
+
+ const handlePointClose = useCallback(
+ () => {
+ setClickedPointProperties(undefined);
+ },
+ [setClickedPointProperties],
+ );
+
+ const handleClearFiltersButtonclick = useCallback(() => {
+ setFilter({});
+ }, [setFilter]);
+
+ const popupDetails = clickedPointProperties
+ ? countryGroupedAppeal[clickedPointProperties.feature.properties.iso3]
+ : undefined;
+
+ const [districtOptions, setDistrictOptions] = useState();
+
+ return (
+
+
+
+ {variant === 'country' && (
+
+ )}
+
+
+
+
+
+ >
+ )}
+ actions={!presentationMode && (
+
+ {allAppealsType.title}
+
+ )}
+ >
+
+ )}
+ >
+
+
+
+ {legendOptions.map((legendItem) => (
+
+ ))}
+
+
+ )}
+ />
+
+
+
+
+ {clickedPointProperties?.lngLat && (
+
+ {clickedPointProperties.feature.properties.name}
+
+ )}
+ childrenContainerClassName={styles.popupContent}
+ >
+ {popupDetails?.map(
+ (appeal) => (
+
+
+
+
+
+ ),
+ )}
+ {(isNotDefined(popupDetails) || popupDetails.length === 0) && (
+
+ {strings.operationPopoverEmpty}
+
+ )}
+
+ )}
+ {isDefined(bbox) && (
+
+ )}
+
+ {onPresentationModeButtonClick && !presentationMode && (
+ }
+ onClick={onPresentationModeButtonClick}
+ variant="secondary"
+ >
+ {strings.presentationModeButton}
+
+ )}
+
+ );
+}
+
+export default ActiveOperationMap;
diff --git a/src/components/domain/ActiveOperationMap/styles.module.css b/app/src/components/domain/ActiveOperationMap/styles.module.css
similarity index 100%
rename from src/components/domain/ActiveOperationMap/styles.module.css
rename to app/src/components/domain/ActiveOperationMap/styles.module.css
diff --git a/app/src/components/domain/ActiveOperationMap/utils.ts b/app/src/components/domain/ActiveOperationMap/utils.ts
new file mode 100644
index 000000000..a8bbeeee8
--- /dev/null
+++ b/app/src/components/domain/ActiveOperationMap/utils.ts
@@ -0,0 +1,111 @@
+import type {
+ CircleLayer,
+ CirclePaint,
+} from 'mapbox-gl';
+
+import {
+ COLOR_BLACK,
+ COLOR_BLUE,
+ COLOR_ORANGE,
+ COLOR_RED,
+ COLOR_YELLOW,
+} from '#utils/constants';
+
+export const COLOR_EMERGENCY_APPEAL = COLOR_RED;
+export const COLOR_DREF = COLOR_YELLOW;
+export const COLOR_EAP = COLOR_BLUE;
+export const COLOR_MULTIPLE_TYPES = COLOR_ORANGE;
+
+// FIXME: these must be a constant defined somewhere else
+export const APPEAL_TYPE_DREF = 0;
+export const APPEAL_TYPE_EMERGENCY = 1;
+// const APPEAL_TYPE_INTERNATIONAL = 2; // TODO: we are not showing this?
+export const APPEAL_TYPE_EAP = 3;
+export const APPEAL_TYPE_MULTIPLE = -1;
+
+const circleColor: CirclePaint['circle-color'] = [
+ 'match',
+ ['get', 'appealType'],
+ APPEAL_TYPE_DREF,
+ COLOR_DREF,
+ APPEAL_TYPE_EMERGENCY,
+ COLOR_EMERGENCY_APPEAL,
+ APPEAL_TYPE_EAP,
+ COLOR_EAP,
+ APPEAL_TYPE_MULTIPLE,
+ COLOR_MULTIPLE_TYPES,
+ COLOR_BLACK,
+];
+
+const basePointPaint: CirclePaint = {
+ 'circle-radius': 5,
+ 'circle-color': circleColor,
+ 'circle-opacity': 0.8,
+};
+
+export const basePointLayerOptions: Omit = {
+ type: 'circle',
+ paint: basePointPaint,
+};
+
+const baseOuterCirclePaint: CirclePaint = {
+ 'circle-color': circleColor,
+ 'circle-opacity': 0.4,
+};
+
+const outerCirclePaintForFinancialRequirements: CirclePaint = {
+ ...baseOuterCirclePaint,
+ 'circle-radius': [
+ 'interpolate',
+ ['linear', 1],
+ ['get', 'financialRequirements'],
+ 1000,
+ 7,
+ 10000,
+ 9,
+ 100000,
+ 11,
+ 1000000,
+ 15,
+ ],
+};
+
+const outerCirclePaintForPeopleTargeted: CirclePaint = {
+ ...baseOuterCirclePaint,
+ 'circle-radius': [
+ 'interpolate',
+ ['linear', 1],
+ ['get', 'peopleTargeted'],
+ 1000,
+ 7,
+ 10000,
+ 9,
+ 100000,
+ 11,
+ 1000000,
+ 15,
+ ],
+};
+
+export const outerCircleLayerOptionsForFinancialRequirements: Omit = {
+ type: 'circle',
+ paint: outerCirclePaintForFinancialRequirements,
+};
+
+export const outerCircleLayerOptionsForPeopleTargeted: Omit = {
+ type: 'circle',
+ paint: outerCirclePaintForPeopleTargeted,
+};
+
+export interface ScaleOption {
+ label: string;
+ value: 'financialRequirements' | 'peopleTargeted';
+}
+
+export function optionKeySelector(option: ScaleOption) {
+ return option.value;
+}
+
+export function optionLabelSelector(option: ScaleOption) {
+ return option.label;
+}
diff --git a/src/components/domain/ActivityEventSearchSelectInput.tsx b/app/src/components/domain/ActivityEventSearchSelectInput.tsx
similarity index 91%
rename from src/components/domain/ActivityEventSearchSelectInput.tsx
rename to app/src/components/domain/ActivityEventSearchSelectInput.tsx
index bc4ee75c3..36ebc61f7 100644
--- a/src/components/domain/ActivityEventSearchSelectInput.tsx
+++ b/app/src/components/domain/ActivityEventSearchSelectInput.tsx
@@ -1,10 +1,15 @@
import { useState } from 'react';
+import {
+ SearchSelectInput,
+ SearchSelectInputProps,
+} from '@ifrc-go/ui';
-import SearchSelectInput, {
- Props as SearchSelectInputProps,
-} from '#components/SearchSelectInput';
-import { useRequest, type GoApiResponse, type GoApiUrlQuery } from '#utils/restRequest';
import useDebouncedValue from '#hooks/useDebouncedValue';
+import {
+ type GoApiResponse,
+ type GoApiUrlQuery,
+ useRequest,
+} from '#utils/restRequest';
type GetEventParams = GoApiUrlQuery<'/api/v2/event/response-activity/'>;
type GetEventResponse = GoApiResponse<'/api/v2/event/response-activity/'>;
diff --git a/src/components/domain/AppealsOverYearsChart/MonthlyChart/i18n.json b/app/src/components/domain/AppealsOverYearsChart/MonthlyChart/i18n.json
similarity index 100%
rename from src/components/domain/AppealsOverYearsChart/MonthlyChart/i18n.json
rename to app/src/components/domain/AppealsOverYearsChart/MonthlyChart/i18n.json
diff --git a/app/src/components/domain/AppealsOverYearsChart/MonthlyChart/index.tsx b/app/src/components/domain/AppealsOverYearsChart/MonthlyChart/index.tsx
new file mode 100644
index 000000000..d9499fdef
--- /dev/null
+++ b/app/src/components/domain/AppealsOverYearsChart/MonthlyChart/index.tsx
@@ -0,0 +1,218 @@
+import {
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ BlockLoading,
+ Button,
+ Container,
+ TimeSeriesChart,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ getDatesSeparatedByMonths,
+ getFormattedDateKey,
+ resolveToComponent,
+} from '@ifrc-go/ui/utils';
+import {
+ encodeDate,
+ isNotDefined,
+ listToMap,
+} from '@togglecorp/fujs';
+
+import { useRequest } from '#utils/restRequest';
+
+import PointDetails from '../PointDetails';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+// FIXME: these must be a constant defined somewhere else
+// with satisfies
+const APPEAL_TYPE_DREF = 0;
+const APPEAL_TYPE_EMERGENCY = 1;
+
+type DATA_KEY = 'dref' | 'emergencyAppeal';
+
+const dataKeys: DATA_KEY[] = [
+ 'dref',
+ 'emergencyAppeal',
+];
+
+const dataKeyToClassNameMap = {
+ dref: styles.dref,
+ emergencyAppeal: styles.emergencyAppeal,
+};
+const classNameSelector = (dataKey: DATA_KEY) => dataKeyToClassNameMap[dataKey];
+const xAxisFormatter = (date: Date) => date.toLocaleString(
+ navigator.language,
+ { month: 'short' },
+);
+
+const dateFormatter = new Intl.DateTimeFormat(
+ navigator.language,
+ { month: 'long' },
+);
+
+const currentDate = new Date();
+
+interface Props {
+ regionId?: number;
+ year: number;
+ onBackButtonClick: (year: undefined) => void;
+}
+
+function MonthlyChart(props: Props) {
+ const {
+ year,
+ regionId,
+ onBackButtonClick,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const dateList = useMemo(
+ () => {
+ const startDate = new Date(year, 0, 1);
+ const endDate = new Date(year, 11, 31);
+ return getDatesSeparatedByMonths(startDate, endDate);
+ },
+ [year],
+ );
+
+ const [activePointKey, setActivePointKey] = useState(
+ () => getFormattedDateKey(dateList[0]),
+ );
+
+ const query = {
+ model_type: 'appeal',
+ start_date: encodeDate(new Date(year, 0, 1)),
+ end_date: encodeDate(new Date(year, 11, 31)),
+ sum_amount_funded: 'amount_funded',
+ sum_beneficiaries: 'num_beneficiaries',
+ unit: 'month',
+ region: regionId,
+ };
+
+ const {
+ pending: monthlyEmergencyAppealPending,
+ response: monthlyEmergencyAppealResponse,
+ } = useRequest({
+ url: '/api/v1/aggregate/',
+ query: {
+ filter_atype: APPEAL_TYPE_EMERGENCY,
+ ...query,
+ // FIXME: need to fix typing in server (low priority)
+ } as never,
+ });
+
+ const {
+ response: monthlyDrefResponse,
+ pending: monthlyDrefPending,
+ } = useRequest({
+ url: '/api/v1/aggregate/',
+ query: {
+ filter_atype: APPEAL_TYPE_DREF,
+ ...query,
+ // FIXME: need to fix the typing in server (low priority)
+ } as never,
+ });
+
+ const pending = monthlyEmergencyAppealPending || monthlyDrefPending;
+
+ const combinedData = useMemo(
+ () => {
+ if (isNotDefined(monthlyDrefResponse) || isNotDefined(monthlyEmergencyAppealResponse)) {
+ return undefined;
+ }
+
+ const drefData = listToMap(
+ monthlyDrefResponse,
+ (appeal) => getFormattedDateKey(appeal.timespan),
+ );
+
+ const emergencyAppealData = listToMap(
+ monthlyEmergencyAppealResponse,
+ (appeal) => getFormattedDateKey(appeal.timespan),
+ );
+
+ const data = {
+ dref: drefData,
+ emergencyAppeal: emergencyAppealData,
+ };
+
+ return data;
+ },
+ [monthlyEmergencyAppealResponse, monthlyDrefResponse],
+ );
+
+ const dateListWithData = listToMap(
+ dateList,
+ (date) => getFormattedDateKey(date),
+ (date, key) => ({
+ date,
+ dref: combinedData?.dref?.[key],
+ emergencyAppeal: combinedData?.emergencyAppeal?.[key],
+ }),
+ );
+
+ const activePointData = activePointKey ? dateListWithData[activePointKey] : undefined;
+ const heading = resolveToComponent(
+ strings.homeMonthlyChartTitle,
+ { year: year ?? '--' },
+ );
+ const chartValueSelector = useCallback(
+ (dataKey: DATA_KEY, date: Date) => {
+ const value = combinedData?.[dataKey]?.[getFormattedDateKey(date)]?.count;
+ // NOTE: if there are missing values for a given month or year
+ // less then the current date we assume the value to be 0
+ // FIXME: This could be done in the aggregation logic of the server itself
+ if (isNotDefined(value) && date < currentDate) {
+ return 0;
+ }
+
+ return combinedData?.[dataKey]?.[getFormattedDateKey(date)]?.count;
+ },
+ [combinedData],
+ );
+
+ return (
+
+ {pending && }
+ {!pending && (
+ <>
+
+
+ {strings.homeMonthlyChartBackButtonLabel}
+
+ )}
+ />
+ >
+ )}
+
+ );
+}
+
+export default MonthlyChart;
diff --git a/src/components/domain/AppealsOverYearsChart/MonthlyChart/styles.module.css b/app/src/components/domain/AppealsOverYearsChart/MonthlyChart/styles.module.css
similarity index 100%
rename from src/components/domain/AppealsOverYearsChart/MonthlyChart/styles.module.css
rename to app/src/components/domain/AppealsOverYearsChart/MonthlyChart/styles.module.css
diff --git a/src/components/domain/AppealsOverYearsChart/PointDetails/i18n.json b/app/src/components/domain/AppealsOverYearsChart/PointDetails/i18n.json
similarity index 100%
rename from src/components/domain/AppealsOverYearsChart/PointDetails/i18n.json
rename to app/src/components/domain/AppealsOverYearsChart/PointDetails/i18n.json
diff --git a/app/src/components/domain/AppealsOverYearsChart/PointDetails/index.tsx b/app/src/components/domain/AppealsOverYearsChart/PointDetails/index.tsx
new file mode 100644
index 000000000..9d0b54d60
--- /dev/null
+++ b/app/src/components/domain/AppealsOverYearsChart/PointDetails/index.tsx
@@ -0,0 +1,98 @@
+import {
+ Container,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { _cs } from '@togglecorp/fujs';
+
+import { type GoApiResponse } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type AggregateResponse = GoApiResponse<'/api/v1/aggregate/'>;
+type AggregateItem = AggregateResponse[number];
+
+interface Props {
+ data?: {
+ date: Date;
+ dref?: AggregateItem;
+ emergencyAppeal?: AggregateItem;
+ }
+ action?: React.ReactNode;
+ className?: string;
+ heading?: React.ReactNode;
+}
+
+function PointDetails(props: Props) {
+ const {
+ className,
+ data,
+ action,
+ heading,
+ } = props;
+ const strings = useTranslation(i18n);
+
+ return (
+
+ {data && (
+ <>
+
+ }
+ label={strings.timelineChartEmergencyAppealLabel}
+ value={data.emergencyAppeal?.count ?? 0}
+ valueType="number"
+ strongValue
+ strongLabel
+ />
+
+
+
+
+ }
+ label={strings.timelineChartDrefLabel}
+ value={data.dref?.count ?? 0}
+ valueType="number"
+ strongValue
+ strongLabel
+ />
+
+
+
+ >
+ )}
+
+ );
+}
+
+export default PointDetails;
diff --git a/src/components/domain/AppealsOverYearsChart/PointDetails/styles.module.css b/app/src/components/domain/AppealsOverYearsChart/PointDetails/styles.module.css
similarity index 100%
rename from src/components/domain/AppealsOverYearsChart/PointDetails/styles.module.css
rename to app/src/components/domain/AppealsOverYearsChart/PointDetails/styles.module.css
diff --git a/src/components/domain/AppealsOverYearsChart/YearlyChart/i18n.json b/app/src/components/domain/AppealsOverYearsChart/YearlyChart/i18n.json
similarity index 100%
rename from src/components/domain/AppealsOverYearsChart/YearlyChart/i18n.json
rename to app/src/components/domain/AppealsOverYearsChart/YearlyChart/i18n.json
diff --git a/app/src/components/domain/AppealsOverYearsChart/YearlyChart/index.tsx b/app/src/components/domain/AppealsOverYearsChart/YearlyChart/index.tsx
new file mode 100644
index 000000000..ae56b84eb
--- /dev/null
+++ b/app/src/components/domain/AppealsOverYearsChart/YearlyChart/index.tsx
@@ -0,0 +1,201 @@
+import {
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ BlockLoading,
+ Button,
+ Container,
+ Message,
+ TimeSeriesChart,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ getDatesSeparatedByYear,
+ getFormattedDateKey,
+} from '@ifrc-go/ui/utils';
+import {
+ encodeDate,
+ isDefined,
+ isNotDefined,
+ listToMap,
+} from '@togglecorp/fujs';
+
+import { useRequest } from '#utils/restRequest';
+
+import PointDetails from '../PointDetails';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+const APPEAL_TYPE_EMERGENCY = 1;
+const APPEAL_TYPE_DREF = 0;
+
+type DATA_KEY = 'dref' | 'emergencyAppeal';
+
+const dataKeys: DATA_KEY[] = [
+ 'dref',
+ 'emergencyAppeal',
+];
+
+// FIXME: use a separate utility
+const now = new Date();
+const startDate = new Date(now.getFullYear() - 10, 0, 1);
+const endDate = new Date(now.getFullYear(), 11, 31);
+const dateList = getDatesSeparatedByYear(startDate, endDate);
+
+const dataKeyToClassNameMap = {
+ dref: styles.dref,
+ emergencyAppeal: styles.emergencyAppeal,
+};
+const classNameSelector = (dataKey: DATA_KEY) => dataKeyToClassNameMap[dataKey];
+const xAxisFormatter = (date: Date) => date.toLocaleString(
+ navigator.language,
+ { year: 'numeric' },
+);
+
+interface Props {
+ onYearClick: (year: number) => void;
+ regionId?: number;
+}
+
+function YearlyChart(props: Props) {
+ const {
+ onYearClick,
+ regionId,
+ } = props;
+ const strings = useTranslation(i18n);
+
+ const [activePointKey, setActivePointKey] = useState(
+ () => getFormattedDateKey(dateList[dateList.length - 1]),
+ );
+
+ const queryParams = {
+ model_type: 'appeal',
+ start_date: encodeDate(startDate),
+ end_date: encodeDate(endDate),
+ sum_amount_funded: 'amount_funded',
+ sum_beneficiaries: 'num_beneficiaries',
+ unit: 'year',
+ region: regionId,
+ };
+
+ const {
+ pending: monthlyEmergencyAppealPending,
+ response: monthlyEmergencyAppealResponse,
+ error: appealResponseError,
+ } = useRequest({
+ url: '/api/v1/aggregate/',
+ query: {
+ filter_atype: APPEAL_TYPE_EMERGENCY,
+ ...queryParams,
+ // FIXME: fix typing in server (low priority)
+ } as never,
+ });
+
+ const {
+ response: monthlyDrefResponse,
+ pending: monthlyDrefPending,
+ } = useRequest({
+ url: '/api/v1/aggregate/',
+ query: {
+ filter_atype: APPEAL_TYPE_DREF,
+ ...queryParams,
+ // FIXME: fix typing in server (low priority)
+ } as never,
+ });
+
+ const pending = monthlyEmergencyAppealPending || monthlyDrefPending;
+
+ const combinedData = useMemo(
+ () => {
+ if (isNotDefined(monthlyDrefResponse) || isNotDefined(monthlyEmergencyAppealResponse)) {
+ return undefined;
+ }
+
+ const drefData = listToMap(
+ monthlyDrefResponse,
+ (appeal) => getFormattedDateKey(appeal.timespan),
+ );
+
+ const emergencyAppealData = listToMap(
+ monthlyEmergencyAppealResponse,
+ (appeal) => getFormattedDateKey(appeal.timespan),
+ );
+
+ const data = {
+ dref: drefData,
+ emergencyAppeal: emergencyAppealData,
+ };
+
+ return data;
+ },
+ [monthlyEmergencyAppealResponse, monthlyDrefResponse],
+ );
+
+ const dateListWithData = listToMap(
+ dateList,
+ (date) => getFormattedDateKey(date),
+ (date, key) => ({
+ date,
+ dref: combinedData?.dref?.[key],
+ emergencyAppeal: combinedData?.emergencyAppeal?.[key],
+ }),
+ );
+
+ const activePointData = activePointKey ? dateListWithData[activePointKey] : undefined;
+ const chartValueSelector = useCallback(
+ (dataKey: DATA_KEY, date: Date) => (
+ combinedData?.[dataKey]?.[getFormattedDateKey(date)]?.count
+ ),
+ [combinedData],
+ );
+
+ const shouldHideChart = pending && isDefined(appealResponseError);
+
+ return (
+
+ {pending && }
+ {isDefined(appealResponseError) && (
+
+ )}
+ {!shouldHideChart && !appealResponseError && (
+ <>
+
+
+ {strings.yearlyAppealChartViewMonthlyLabel}
+
+ )}
+ />
+ >
+ )}
+
+ );
+}
+
+export default YearlyChart;
diff --git a/src/components/domain/AppealsOverYearsChart/YearlyChart/styles.module.css b/app/src/components/domain/AppealsOverYearsChart/YearlyChart/styles.module.css
similarity index 100%
rename from src/components/domain/AppealsOverYearsChart/YearlyChart/styles.module.css
rename to app/src/components/domain/AppealsOverYearsChart/YearlyChart/styles.module.css
diff --git a/app/src/components/domain/AppealsOverYearsChart/index.tsx b/app/src/components/domain/AppealsOverYearsChart/index.tsx
new file mode 100644
index 000000000..0cd06ae09
--- /dev/null
+++ b/app/src/components/domain/AppealsOverYearsChart/index.tsx
@@ -0,0 +1,33 @@
+import { useState } from 'react';
+import { isNotDefined } from '@togglecorp/fujs';
+
+import MonthlyChart from './MonthlyChart';
+import YearlyChart from './YearlyChart';
+
+interface Props {
+ regionId?: number;
+}
+
+function AppealsOverYearsChart(props: Props) {
+ const { regionId } = props;
+ const [year, setYear] = useState();
+
+ if (isNotDefined(year)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export default AppealsOverYearsChart;
diff --git a/src/components/domain/AppealsTable/i18n.json b/app/src/components/domain/AppealsTable/i18n.json
similarity index 100%
rename from src/components/domain/AppealsTable/i18n.json
rename to app/src/components/domain/AppealsTable/i18n.json
diff --git a/app/src/components/domain/AppealsTable/index.tsx b/app/src/components/domain/AppealsTable/index.tsx
new file mode 100644
index 000000000..6e26c63a2
--- /dev/null
+++ b/app/src/components/domain/AppealsTable/index.tsx
@@ -0,0 +1,333 @@
+import {
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ Button,
+ Container,
+ DateInput,
+ Pager,
+ SelectInput,
+ Table,
+} from '@ifrc-go/ui';
+import { SortContext } from '@ifrc-go/ui/contexts';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ createDateColumn,
+ createNumberColumn,
+ createProgressColumn,
+ createStringColumn,
+ getPercentage,
+ hasSomeDefinedValue,
+} from '@ifrc-go/ui/utils';
+import {
+ _cs,
+ isDefined,
+} from '@togglecorp/fujs';
+
+import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput';
+import DistrictSearchMultiSelectInput, { type DistrictItem } from '#components/domain/DistrictSearchMultiSelectInput';
+import useGlobalEnums from '#hooks/domain/useGlobalEnums';
+import useFilterState from '#hooks/useFilterState';
+import { createLinkColumn } from '#utils/domain/tableHelpers';
+import type {
+ GoApiResponse,
+ GoApiUrlQuery,
+} from '#utils/restRequest';
+import { useRequest } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type AppealQueryParams = GoApiUrlQuery<'/api/v2/appeal/'>;
+type AppealResponse = GoApiResponse<'/api/v2/appeal/'>;
+type AppealListItem = NonNullable[number];
+
+type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>;
+type AppealTypeOption = NonNullable[number];
+
+const appealKeySelector = (option: AppealListItem) => option.id;
+const appealTypeKeySelector = (option: AppealTypeOption) => option.key;
+const appealTypeLabelSelector = (option: AppealTypeOption) => option.value;
+
+const now = new Date().toISOString();
+type BaseProps = {
+ className?: string;
+}
+type CountryProps = {
+ variant: 'country';
+ countryId: number;
+}
+
+type RegionProps = {
+ variant: 'region';
+ regionId: number;
+}
+
+type GlobalProps = {
+ variant: 'global';
+}
+
+type Props = BaseProps & (RegionProps | GlobalProps | CountryProps);
+
+function AppealsTable(props: Props) {
+ const {
+ className,
+ variant,
+ } = props;
+
+ const {
+ filter,
+ filtered,
+ limit,
+ offset,
+ ordering,
+ page,
+ rawFilter,
+ setFilter,
+ setFilterField,
+ setPage,
+ sortState,
+ } = useFilterState<{
+ appeal?: AppealTypeOption['key'],
+ district?: number[],
+ displacement?: number,
+ startDateAfter?: string,
+ startDateBefore?: string,
+ }>({
+ filter: {},
+ pageSize: 5,
+ });
+
+ const strings = useTranslation(i18n);
+ const { api_appeal_type: appealTypeOptions } = useGlobalEnums();
+
+ const handleClearFiltersButtonclick = useCallback(() => {
+ setFilter({});
+ }, [setFilter]);
+
+ // eslint-disable-next-line react/destructuring-assignment
+ const regionId = variant === 'region' ? props.regionId : undefined;
+ // eslint-disable-next-line react/destructuring-assignment
+ const countryId = variant === 'country' ? props.countryId : undefined;
+
+ const [districtOptions, setDistrictOptions] = useState();
+
+ const columns = useMemo(
+ () => ([
+ createDateColumn(
+ 'start_date',
+ strings.appealsTableStartDate,
+ (item) => item.start_date,
+ {
+ sortable: true,
+ columnClassName: styles.startDate,
+ },
+ ),
+ createStringColumn(
+ 'atype',
+ strings.appealsTableType,
+ (item) => item.atype_display,
+ {
+ sortable: true,
+ columnClassName: styles.appealType,
+ },
+ ),
+ createStringColumn(
+ 'code',
+ strings.appealsTableCode,
+ (item) => item.code,
+ {
+ columnClassName: styles.code,
+ },
+ ),
+ createLinkColumn(
+ 'operation',
+ strings.appealsTableOperation,
+ (item) => item.name,
+ (item) => ({
+ to: 'emergenciesLayout',
+ urlParams: { emergencyId: item.event },
+ }),
+ ),
+ createStringColumn(
+ 'dtype',
+ strings.appealsTableDisastertype,
+ (item) => item.dtype?.name,
+ { sortable: true },
+ ),
+ createNumberColumn(
+ 'amount_requested',
+ strings.appealsTableRequestedAmount,
+ (item) => item.amount_requested,
+ {
+ sortable: true,
+ suffix: ' CHF',
+ },
+ ),
+ createProgressColumn(
+ 'amount_funded',
+ strings.appealsTableFundedAmount,
+ // FIXME: use progress function
+ (item) => (
+ getPercentage(
+ item.amount_funded,
+ item.amount_requested,
+ )
+ ),
+ { sortable: true },
+ ),
+ variant !== 'country'
+ ? createLinkColumn(
+ 'country',
+ strings.appealsTableCountry,
+ (item) => item.country?.name,
+ (item) => ({
+ to: 'countriesLayout',
+ urlParams: { countryId: item.country.id },
+ }),
+ ) : undefined,
+ ].filter(isDefined)),
+ [
+ variant,
+ strings.appealsTableStartDate,
+ strings.appealsTableType,
+ strings.appealsTableCode,
+ strings.appealsTableOperation,
+ strings.appealsTableDisastertype,
+ strings.appealsTableRequestedAmount,
+ strings.appealsTableFundedAmount,
+ strings.appealsTableCountry,
+ ],
+ );
+
+ const query = useMemo(
+ () => {
+ const baseQuery: AppealQueryParams = {
+ limit,
+ offset,
+ ordering,
+ atype: filter.appeal,
+ dtype: filter.displacement,
+ district: hasSomeDefinedValue(filter.district) ? filter.district : undefined,
+ end_date__gt: now,
+ start_date__gte: filter.startDateAfter,
+ start_date__lte: filter.startDateBefore,
+ };
+
+ if (variant === 'global') {
+ return baseQuery;
+ }
+
+ return {
+ ...baseQuery,
+ country: countryId ? [countryId] : undefined,
+ region: regionId ? [regionId] : undefined,
+ };
+ },
+ [
+ variant,
+ countryId,
+ regionId,
+ ordering,
+ filter,
+ limit,
+ offset,
+ ],
+ );
+
+ const {
+ pending: appealsPending,
+ response: appealsResponse,
+ } = useRequest({
+ url: '/api/v2/appeal/',
+ preserveResponse: true,
+ query,
+ });
+
+ return (
+
+
+
+ {variant === 'country' && (
+
+ )}
+
+
+
+
+
+ >
+ )}
+ footerActions={(
+
+ )}
+ contentViewType="vertical"
+ >
+
+
+
+
+ );
+}
+
+export default AppealsTable;
diff --git a/src/components/domain/AppealsTable/styles.module.css b/app/src/components/domain/AppealsTable/styles.module.css
similarity index 100%
rename from src/components/domain/AppealsTable/styles.module.css
rename to app/src/components/domain/AppealsTable/styles.module.css
diff --git a/app/src/components/domain/BaseMap/index.tsx b/app/src/components/domain/BaseMap/index.tsx
new file mode 100644
index 000000000..47abb62bb
--- /dev/null
+++ b/app/src/components/domain/BaseMap/index.tsx
@@ -0,0 +1,143 @@
+import { useMemo } from 'react';
+import {
+ isDefined,
+ isFalsyString,
+ isNotDefined,
+} from '@togglecorp/fujs';
+import Map, {
+ MapLayer,
+ MapSource,
+} from '@togglecorp/re-map';
+import { type SymbolLayer } from 'mapbox-gl';
+
+import useCountry from '#hooks/domain/useCountry';
+import {
+ adminLabelLayerOptions,
+ defaultMapOptions,
+ defaultMapStyle,
+ defaultNavControlOptions,
+ defaultNavControlPosition,
+} from '#utils/map';
+
+type MapProps = Parameters[0];
+
+type overrides = 'mapStyle' | 'mapOptions' | 'navControlShown' | 'navControlPosition' | 'navControlOptions' | 'scaleControlShown';
+
+type BaseMapProps = Omit & {
+ baseLayers?: React.ReactNode;
+ withDisclaimer?: boolean;
+} & Partial>;
+
+const sourceOptions: mapboxgl.GeoJSONSourceRaw = {
+ type: 'geojson',
+};
+
+const adminLabelOverrideOptions: Omit = {
+ type: 'symbol',
+ layout: {
+ 'text-field': ['get', 'name'],
+ 'text-font': ['Poppins Regular', 'Arial Unicode MS Regular'],
+ 'text-letter-spacing': 0.15,
+ 'text-line-height': 1.2,
+ 'text-max-width': 8,
+ 'text-justify': 'center',
+ 'text-anchor': 'top',
+ 'text-padding': 2,
+ 'text-size': [
+ 'interpolate', ['linear', 1], ['zoom'],
+ 0, 6,
+ 6, 16,
+ ],
+ },
+ paint: {
+ 'text-color': '#000000',
+ 'text-halo-color': '#555555',
+ 'text-halo-width': 0.2,
+ },
+};
+
+function BaseMap(props: BaseMapProps) {
+ const {
+ baseLayers,
+ mapStyle,
+ mapOptions,
+ navControlShown,
+ navControlPosition,
+ navControlOptions,
+ scaleControlShown,
+ children,
+ ...otherProps
+ } = props;
+
+ const countries = useCountry();
+
+ const countryCentroidGeoJson = useMemo(
+ (): GeoJSON.FeatureCollection => ({
+ type: 'FeatureCollection' as const,
+ features: countries
+ ?.map((country) => {
+ if (isFalsyString(country.name) || isNotDefined(country.centroid)) {
+ return undefined;
+ }
+
+ return {
+ type: 'Feature' as const,
+ geometry: country.centroid as {
+ type: 'Point',
+ coordinates: [number, number],
+ },
+ properties: {
+ id: country.id,
+ name: country.name,
+ },
+ };
+ }).filter(isDefined) ?? [],
+ }),
+ [countries],
+ );
+
+ return (
+
+ );
+}
+
+export default BaseMap;
diff --git a/src/components/domain/CountryMultiSelectInput.tsx b/app/src/components/domain/CountryMultiSelectInput.tsx
similarity index 84%
rename from src/components/domain/CountryMultiSelectInput.tsx
rename to app/src/components/domain/CountryMultiSelectInput.tsx
index 20f90d776..dad4662cc 100644
--- a/src/components/domain/CountryMultiSelectInput.tsx
+++ b/app/src/components/domain/CountryMultiSelectInput.tsx
@@ -1,6 +1,12 @@
-import type { MultiSelectInputProps } from '#components/MultiSelectInput';
-import MultiSelectInput from '#components/MultiSelectInput';
-import { numericIdSelector, stringNameSelector } from '#utils/selectors';
+import {
+ MultiSelectInput,
+ MultiSelectInputProps,
+} from '@ifrc-go/ui';
+import {
+ numericIdSelector,
+ stringNameSelector,
+} from '@ifrc-go/ui/utils';
+
import useCountry, { Country } from '#hooks/domain/useCountry';
export type CountryOption = Country;
diff --git a/src/components/domain/CountrySelectInput.tsx b/app/src/components/domain/CountrySelectInput.tsx
similarity index 84%
rename from src/components/domain/CountrySelectInput.tsx
rename to app/src/components/domain/CountrySelectInput.tsx
index effbdcd2a..3ffbc1173 100644
--- a/src/components/domain/CountrySelectInput.tsx
+++ b/app/src/components/domain/CountrySelectInput.tsx
@@ -1,6 +1,12 @@
-import type { Props as SelectInputProps } from '#components/SelectInput';
-import SelectInput from '#components/SelectInput';
-import { numericIdSelector, stringNameSelector } from '#utils/selectors';
+import {
+ SelectInput,
+ SelectInputProps,
+} from '@ifrc-go/ui';
+import {
+ numericIdSelector,
+ stringNameSelector,
+} from '@ifrc-go/ui/utils';
+
import useCountry, { Country } from '#hooks/domain/useCountry';
export type CountryOption = Country;
diff --git a/src/components/domain/DetailsFailedToLoadMessage/i18n.json b/app/src/components/domain/DetailsFailedToLoadMessage/i18n.json
similarity index 100%
rename from src/components/domain/DetailsFailedToLoadMessage/i18n.json
rename to app/src/components/domain/DetailsFailedToLoadMessage/i18n.json
diff --git a/app/src/components/domain/DetailsFailedToLoadMessage/index.tsx b/app/src/components/domain/DetailsFailedToLoadMessage/index.tsx
new file mode 100644
index 000000000..610ea229b
--- /dev/null
+++ b/app/src/components/domain/DetailsFailedToLoadMessage/index.tsx
@@ -0,0 +1,31 @@
+import { Message } from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import i18n from './i18n.json';
+
+interface Props {
+ title?: React.ReactNode;
+ description: React.ReactNode;
+ helpText?: React.ReactNode;
+}
+
+function DetailsFailedToLoadMessage(props: Props) {
+ const strings = useTranslation(i18n);
+
+ const {
+ title = strings.detailsFailedToLoadErrorTitle,
+ description,
+ helpText = strings.detailsFailedToLoadErrorHelpText,
+ } = props;
+
+ return (
+
+ );
+}
+
+export default DetailsFailedToLoadMessage;
diff --git a/src/components/domain/DisasterTypeSelectInput.tsx b/app/src/components/domain/DisasterTypeSelectInput.tsx
similarity index 92%
rename from src/components/domain/DisasterTypeSelectInput.tsx
rename to app/src/components/domain/DisasterTypeSelectInput.tsx
index 28cce2e29..14fe14d43 100644
--- a/src/components/domain/DisasterTypeSelectInput.tsx
+++ b/app/src/components/domain/DisasterTypeSelectInput.tsx
@@ -1,7 +1,10 @@
-import type { Props as SelectInputProps } from '#components/SelectInput';
-import SelectInput from '#components/SelectInput';
-import useDisasterType from '#hooks/domain/useDisasterType';
+import {
+ SelectInput,
+ SelectInputProps,
+} from '@ifrc-go/ui';
+
import { DisasterTypes } from '#contexts/domain';
+import useDisasterType from '#hooks/domain/useDisasterType';
export type DisasterTypeItem = NonNullable[number];
diff --git a/src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx b/app/src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx
similarity index 97%
rename from src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx
rename to app/src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx
index 9805d11bb..df399aa36 100644
--- a/src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx
+++ b/app/src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx
@@ -1,13 +1,13 @@
import { useState } from 'react';
-import { isNotDefined } from '@togglecorp/fujs';
-
-import SearchMultiSelectInput, {
+import {
+ SearchMultiSelectInput,
SearchMultiSelectInputProps,
-} from '#components/SearchMultiSelectInput';
+} from '@ifrc-go/ui';
+import { isNotDefined } from '@togglecorp/fujs';
-import { useRequest } from '#utils/restRequest';
-import useDebouncedValue from '#hooks/useDebouncedValue';
import { paths } from '#generated/types';
+import useDebouncedValue from '#hooks/useDebouncedValue';
+import { useRequest } from '#utils/restRequest';
type GetDistrict = paths['/api/v2/district/']['get'];
type GetDistrictParams = GetDistrict['parameters']['query'];
diff --git a/src/components/domain/DistrictSearchMultiSelectInput/i18n.json b/app/src/components/domain/DistrictSearchMultiSelectInput/i18n.json
similarity index 100%
rename from src/components/domain/DistrictSearchMultiSelectInput/i18n.json
rename to app/src/components/domain/DistrictSearchMultiSelectInput/i18n.json
diff --git a/app/src/components/domain/DistrictSearchMultiSelectInput/index.tsx b/app/src/components/domain/DistrictSearchMultiSelectInput/index.tsx
new file mode 100644
index 000000000..5cc27b880
--- /dev/null
+++ b/app/src/components/domain/DistrictSearchMultiSelectInput/index.tsx
@@ -0,0 +1,149 @@
+import {
+ useCallback,
+ useState,
+} from 'react';
+import { CheckDoubleFillIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ SearchMultiSelectInput,
+ SearchMultiSelectInputProps,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ isNotDefined,
+ unique,
+} from '@togglecorp/fujs';
+
+import useDebouncedValue from '#hooks/useDebouncedValue';
+import {
+ type GoApiResponse,
+ type GoApiUrlQuery,
+ useLazyRequest,
+ useRequest,
+} from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type GetDistrictParams = GoApiUrlQuery<'/api/v2/district/'>;
+type GetDistrictResponse = GoApiResponse<'/api/v2/district/'>;
+
+export type DistrictItem = Pick[number], 'id' | 'name'>;
+
+const keySelector = (d: DistrictItem) => d.id;
+const labelSelector = (d: DistrictItem) => d.name;
+
+type Def = { containerClassName?: string;}
+type DistrictMultiSelectInputProps = SearchMultiSelectInputProps<
+ number,
+ NAME,
+ DistrictItem,
+ Def,
+ 'onSearchValueChange' | 'searchOptions' | 'optionsPending'
+ | 'keySelector' | 'labelSelector' | 'totalOptionsCount' | 'onShowDropdownChange'
+ | 'selectedOnTop'
+> & {
+ countryId?: number;
+};
+
+function DistrictSearchMultiSelectInput(
+ props: DistrictMultiSelectInputProps,
+) {
+ const {
+ className,
+ countryId,
+ onChange,
+ onOptionsChange,
+ name,
+ disabled,
+ readOnly,
+ ...otherProps
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const [opened, setOpened] = useState(false);
+ const [searchText, setSearchText] = useState('');
+ const debouncedSearchText = useDebouncedValue(searchText);
+
+ const query: GetDistrictParams = {
+ country: countryId,
+ search: debouncedSearchText,
+ limit: 20,
+ };
+
+ const {
+ pending,
+ response,
+ } = useRequest({
+ skip: isNotDefined(countryId) || !opened,
+ url: '/api/v2/district/',
+ query,
+ preserveResponse: true,
+ });
+
+ const {
+ pending: pendingSelectAll,
+ trigger,
+ } = useLazyRequest({
+ method: 'GET',
+ url: '/api/v2/district/',
+ query: (ctx: GetDistrictParams) => ctx,
+ onSuccess: (allDistricts) => {
+ const allDistrictsKeys = allDistricts.results?.map((d) => d.id);
+ if (allDistrictsKeys && allDistrictsKeys.length > 0) {
+ onChange(allDistrictsKeys, name);
+ if (onOptionsChange) {
+ onOptionsChange(((existingOptions) => {
+ const safeOptions = existingOptions ?? [];
+ return unique(
+ [...safeOptions, ...(allDistricts.results ?? [])],
+ keySelector,
+ );
+ }));
+ }
+ }
+ },
+ });
+
+ const handleSelectAllClick = useCallback(() => {
+ trigger({
+ country: countryId,
+ limit: 9999,
+ });
+ }, [trigger, countryId]);
+
+ return (
+
+
+
+ )}
+ selectedOnTop
+ />
+ );
+}
+
+export default DistrictSearchMultiSelectInput;
diff --git a/src/components/domain/DistrictSearchMultiSelectInput/styles.module.css b/app/src/components/domain/DistrictSearchMultiSelectInput/styles.module.css
similarity index 100%
rename from src/components/domain/DistrictSearchMultiSelectInput/styles.module.css
rename to app/src/components/domain/DistrictSearchMultiSelectInput/styles.module.css
diff --git a/src/components/domain/DrefExportModal/i18n.json b/app/src/components/domain/DrefExportModal/i18n.json
similarity index 100%
rename from src/components/domain/DrefExportModal/i18n.json
rename to app/src/components/domain/DrefExportModal/i18n.json
diff --git a/app/src/components/domain/DrefExportModal/index.tsx b/app/src/components/domain/DrefExportModal/index.tsx
new file mode 100644
index 000000000..fd7acf67d
--- /dev/null
+++ b/app/src/components/domain/DrefExportModal/index.tsx
@@ -0,0 +1,147 @@
+import {
+ useMemo,
+ useState,
+} from 'react';
+import {
+ Message,
+ Modal,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import Link from '#components/Link';
+import { type components } from '#generated/types';
+import { useRequest } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+
+type ExportTypeEnum = components<'read'>['schemas']['ExportTypeEnum'];
+type ExportStatusEnum = components<'read'>['schemas']['Status1d2Enum'];
+
+const EXPORT_STATUS_PENDING = 0 satisfies ExportStatusEnum;
+const EXPORT_STATUS_COMPLETED = 1 satisfies ExportStatusEnum;
+const EXPORT_STATUS_ERRORED = 2 satisfies ExportStatusEnum;
+
+interface Props {
+ id: number;
+ onCancel: () => void;
+ applicationType: 'DREF' | 'OPS_UPDATE' | 'FINAL_REPORT';
+}
+
+function DrefExportModal(props: Props) {
+ const {
+ id,
+ onCancel,
+ applicationType,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const [exportId, setExportId] = useState();
+
+ const exportTriggerBody = useMemo(
+ () => {
+ let type: ExportTypeEnum;
+ if (applicationType === 'OPS_UPDATE') {
+ type = 'dref-operational-updates';
+ } else if (applicationType === 'FINAL_REPORT') {
+ type = 'dref-final-reports';
+ } else {
+ type = 'dref-applications';
+ }
+
+ return {
+ export_id: id,
+ export_type: type,
+ selector: '#pdf-preview-ready',
+ };
+ },
+ [id, applicationType],
+ );
+
+ const {
+ pending: pendingExportTrigger,
+ error: exportTriggerError,
+ } = useRequest({
+ skip: isDefined(exportId) || isNotDefined(id),
+ method: 'POST',
+ useCurrentLanguageForMutation: true,
+ url: '/api/v2/pdf-export/',
+ body: exportTriggerBody,
+ onSuccess: (response) => {
+ if (isDefined(response.id)) {
+ setExportId(response.id);
+ }
+ },
+ });
+
+ const {
+ pending: pendingExportStatus,
+ response: exportStatusResponse,
+ error: exportStatusError,
+ } = useRequest({
+ skip: isNotDefined(exportId),
+ url: '/api/v2/pdf-export/{id}/',
+ // FIXME: typings should be fixed in the server
+ pathVariables: isDefined(exportId) ? ({ id: String(exportId) }) : undefined,
+ shouldPoll: (poll) => {
+ if (poll?.errored || poll?.value?.status !== EXPORT_STATUS_PENDING) {
+ return -1;
+ }
+
+ return 5000;
+ },
+ });
+
+ return (
+
+ {pendingExportTrigger && (
+
+ )}
+ {(pendingExportStatus || exportStatusResponse?.status === EXPORT_STATUS_PENDING) && (
+
+ )}
+ {(exportStatusResponse?.status === EXPORT_STATUS_ERRORED
+ || isDefined(exportTriggerError)
+ || isDefined(exportStatusError)
+ ) && (
+
+ )}
+ {isDefined(exportStatusResponse)
+ && exportStatusResponse.status === EXPORT_STATUS_COMPLETED
+ && isDefined(exportStatusResponse.pdf_file) && (
+
+ {strings.drefDownloadPDF}
+
+ )}
+ />
+ )}
+
+ );
+}
+
+export default DrefExportModal;
diff --git a/src/components/domain/DrefShareModal/UserItem/i18n.json b/app/src/components/domain/DrefShareModal/UserItem/i18n.json
similarity index 100%
rename from src/components/domain/DrefShareModal/UserItem/i18n.json
rename to app/src/components/domain/DrefShareModal/UserItem/i18n.json
diff --git a/app/src/components/domain/DrefShareModal/UserItem/index.tsx b/app/src/components/domain/DrefShareModal/UserItem/index.tsx
new file mode 100644
index 000000000..5e8f93668
--- /dev/null
+++ b/app/src/components/domain/DrefShareModal/UserItem/index.tsx
@@ -0,0 +1,58 @@
+import { useMemo } from 'react';
+import { DeleteBinFillIcon } from '@ifrc-go/icons';
+import { Button } from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ _cs,
+ isDefined,
+} from '@togglecorp/fujs';
+
+import { type User } from '#components/domain/UserSearchMultiSelectInput';
+import { getUserName } from '#utils/domain/user';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+interface Props {
+ className?: string;
+ userId: number;
+ user: User;
+ onUserRemove?: (item: number) => void;
+}
+
+function UserItem(props: Props) {
+ const {
+ className,
+ userId,
+ user,
+ onUserRemove,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const userName = useMemo(
+ () => getUserName(user),
+ [user],
+ );
+
+ return (
+
+
+ {userName}
+
+ {isDefined(onUserRemove) && (
+
+ )}
+
+ );
+}
+
+export default UserItem;
diff --git a/src/components/domain/DrefShareModal/UserItem/styles.module.css b/app/src/components/domain/DrefShareModal/UserItem/styles.module.css
similarity index 100%
rename from src/components/domain/DrefShareModal/UserItem/styles.module.css
rename to app/src/components/domain/DrefShareModal/UserItem/styles.module.css
diff --git a/src/components/domain/DrefShareModal/i18n.json b/app/src/components/domain/DrefShareModal/i18n.json
similarity index 100%
rename from src/components/domain/DrefShareModal/i18n.json
rename to app/src/components/domain/DrefShareModal/i18n.json
diff --git a/app/src/components/domain/DrefShareModal/index.tsx b/app/src/components/domain/DrefShareModal/index.tsx
new file mode 100644
index 000000000..ee35cb26b
--- /dev/null
+++ b/app/src/components/domain/DrefShareModal/index.tsx
@@ -0,0 +1,147 @@
+import {
+ useCallback,
+ useMemo,
+} from 'react';
+import {
+ Button,
+ List,
+ Modal,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import UserSearchMultiSelectInput, { type User } from '#components/domain/UserSearchMultiSelectInput';
+import useAlert from '#hooks/useAlert';
+import useInputState from '#hooks/useInputState';
+import {
+ useLazyRequest,
+ useRequest,
+} from '#utils/restRequest';
+
+import UserItem from './UserItem';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+interface Props {
+ drefId: number;
+ onCancel: () => void;
+ onSuccess: () => void;
+}
+
+const userKeySelector = (item: User) => item.id;
+
+function DrefShareModal(props: Props) {
+ const {
+ drefId,
+ onCancel,
+ onSuccess,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const alert = useAlert();
+ const [users, setUsers] = useInputState([]);
+ const [userOptions, setUserOptions] = useInputState([]);
+
+ const {
+ pending: updatePending,
+ trigger: triggerUpdate,
+ } = useLazyRequest({
+ method: 'POST',
+ url: '/api/v2/dref-share/',
+ body: () => ({
+ dref: drefId,
+ users,
+ }),
+ onSuccess: () => {
+ alert.show(
+ strings.drefShareSuccessfully,
+ { variant: 'success' },
+ );
+ onSuccess();
+ },
+ });
+
+ const {
+ pending: getPending,
+ // response: usersResponse,
+ } = useRequest({
+ skip: isNotDefined(drefId),
+ url: '/api/v2/dref-share-user/{id}/',
+ pathVariables: { id: drefId },
+ onSuccess: (response) => {
+ if (isDefined(response.users)) {
+ setUsers(response.users);
+ }
+
+ setUserOptions(response.users_details);
+ },
+ });
+
+ const handleUserRemove = useCallback((userId: number) => {
+ setUsers((oldVal = []) => (
+ oldVal.filter((item) => item !== userId)
+ ));
+ }, [setUsers]);
+
+ const selectedUsers = useMemo(() => (
+ userOptions?.filter((user) => users.includes(user.id))
+ ), [userOptions, users]);
+
+ const userRendererParams = useCallback((userId: number, user: User) => ({
+ userId,
+ user,
+ onUserRemove: handleUserRemove,
+ }), [
+ handleUserRemove,
+ ]);
+
+ return (
+
+ {strings.drefShareUpdate}
+
+ )}
+ size="md"
+ childrenContainerClassName={styles.content}
+ >
+
+
+
+ );
+}
+
+export default DrefShareModal;
diff --git a/src/components/domain/DrefShareModal/styles.module.css b/app/src/components/domain/DrefShareModal/styles.module.css
similarity index 100%
rename from src/components/domain/DrefShareModal/styles.module.css
rename to app/src/components/domain/DrefShareModal/styles.module.css
diff --git a/src/components/domain/EventSearchSelectInput.tsx b/app/src/components/domain/EventSearchSelectInput.tsx
similarity index 95%
rename from src/components/domain/EventSearchSelectInput.tsx
rename to app/src/components/domain/EventSearchSelectInput.tsx
index e1a2d58f3..155ae7bd7 100644
--- a/src/components/domain/EventSearchSelectInput.tsx
+++ b/app/src/components/domain/EventSearchSelectInput.tsx
@@ -1,15 +1,15 @@
import { useState } from 'react';
+import {
+ SearchSelectInput,
+ SearchSelectInputProps,
+} from '@ifrc-go/ui';
-import SearchSelectInput, {
- Props as SearchSelectInputProps,
-} from '#components/SearchSelectInput';
-
+import useDebouncedValue from '#hooks/useDebouncedValue';
import {
- useRequest,
- type GoApiUrlQuery,
type GoApiResponse,
+ type GoApiUrlQuery,
+ useRequest,
} from '#utils/restRequest';
-import useDebouncedValue from '#hooks/useDebouncedValue';
type GetEventParams = GoApiUrlQuery<'/api/v2/event/mini/'>;
type GetEventResponse = GoApiResponse<'/api/v2/event/mini/'>;
diff --git a/src/components/domain/ExportButton/i18n.json b/app/src/components/domain/ExportButton/i18n.json
similarity index 100%
rename from src/components/domain/ExportButton/i18n.json
rename to app/src/components/domain/ExportButton/i18n.json
diff --git a/app/src/components/domain/ExportButton/index.tsx b/app/src/components/domain/ExportButton/index.tsx
new file mode 100644
index 000000000..87d0e7444
--- /dev/null
+++ b/app/src/components/domain/ExportButton/index.tsx
@@ -0,0 +1,65 @@
+import { useMemo } from 'react';
+import { DownloadTwoLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ NumberOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { resolveToComponent } from '@ifrc-go/ui/utils';
+
+import i18n from './i18n.json';
+
+interface Props {
+ onClick: () => void;
+ disabled?: boolean;
+ progress: number;
+ pendingExport: boolean;
+ totalCount: number | undefined;
+}
+
+function ExportButton(props: Props) {
+ const {
+ onClick,
+ disabled,
+ progress,
+ pendingExport,
+ totalCount = 0,
+ } = props;
+ const strings = useTranslation(i18n);
+
+ const exportButtonLabel = useMemo(() => {
+ if (!pendingExport) {
+ return strings.exportTableButtonLabel;
+ }
+ return resolveToComponent(
+ strings.exportTableDownloadingButtonLabel,
+ {
+ progress: (
+
+ ),
+ },
+ );
+ }, [
+ strings.exportTableButtonLabel,
+ strings.exportTableDownloadingButtonLabel,
+ progress,
+ pendingExport,
+ ]);
+
+ return (
+ }
+ disabled={totalCount < 1 || pendingExport || disabled}
+ variant="secondary"
+ >
+ {exportButtonLabel}
+
+ );
+}
+
+export default ExportButton;
diff --git a/src/components/domain/FieldReportSearchSelectInput.tsx b/app/src/components/domain/FieldReportSearchSelectInput.tsx
similarity index 93%
rename from src/components/domain/FieldReportSearchSelectInput.tsx
rename to app/src/components/domain/FieldReportSearchSelectInput.tsx
index 63da28e3e..07e5889df 100644
--- a/src/components/domain/FieldReportSearchSelectInput.tsx
+++ b/app/src/components/domain/FieldReportSearchSelectInput.tsx
@@ -1,15 +1,15 @@
import { useState } from 'react';
+import {
+ SearchSelectInput,
+ SearchSelectInputProps,
+} from '@ifrc-go/ui';
-import SearchSelectInput, {
- Props,
-} from '#components/SearchSelectInput';
-
+import useDebouncedValue from '#hooks/useDebouncedValue';
import {
- useRequest,
- type GoApiUrlQuery,
type GoApiResponse,
+ type GoApiUrlQuery,
+ useRequest,
} from '#utils/restRequest';
-import useDebouncedValue from '#hooks/useDebouncedValue';
type GetFieldReportParams = GoApiUrlQuery<'/api/v2/field-report/'>;
type GetFieldReportResponse = GoApiResponse<'/api/v2/field-report/'>;
@@ -19,7 +19,7 @@ const keySelector = (d: FieldReportItem) => d.id;
const labelSelector = (d: FieldReportItem) => d.summary || '???';
type Def = { containerClassName?: string;}
-type FieldReportSelectInputProps = Props<
+type FieldReportSelectInputProps = SearchSelectInputProps<
number,
NAME,
FieldReportItem,
diff --git a/src/components/domain/FormFailedToLoadMessage/i18n.json b/app/src/components/domain/FormFailedToLoadMessage/i18n.json
similarity index 100%
rename from src/components/domain/FormFailedToLoadMessage/i18n.json
rename to app/src/components/domain/FormFailedToLoadMessage/i18n.json
diff --git a/app/src/components/domain/FormFailedToLoadMessage/index.tsx b/app/src/components/domain/FormFailedToLoadMessage/index.tsx
new file mode 100644
index 000000000..92b6d8052
--- /dev/null
+++ b/app/src/components/domain/FormFailedToLoadMessage/index.tsx
@@ -0,0 +1,31 @@
+import { Message } from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import i18n from './i18n.json';
+
+interface Props {
+ title?: React.ReactNode;
+ description: React.ReactNode;
+ helpText?: React.ReactNode;
+}
+
+function FormFailedToLoadMessage(props: Props) {
+ const strings = useTranslation(i18n);
+
+ const {
+ title = strings.formFailedToLoadErrorTitle,
+ description,
+ helpText = strings.formFailedToLoadErrorHelpText,
+ } = props;
+
+ return (
+
+ );
+}
+
+export default FormFailedToLoadMessage;
diff --git a/src/components/domain/GoMultiFileInput/i18n.json b/app/src/components/domain/GoMultiFileInput/i18n.json
similarity index 100%
rename from src/components/domain/GoMultiFileInput/i18n.json
rename to app/src/components/domain/GoMultiFileInput/i18n.json
diff --git a/app/src/components/domain/GoMultiFileInput/index.tsx b/app/src/components/domain/GoMultiFileInput/index.tsx
new file mode 100644
index 000000000..3adeee262
--- /dev/null
+++ b/app/src/components/domain/GoMultiFileInput/index.tsx
@@ -0,0 +1,236 @@
+import React, {
+ useCallback,
+ useRef,
+} from 'react';
+import { DeleteBinFillIcon } from '@ifrc-go/icons';
+import type { ButtonVariant } from '@ifrc-go/ui';
+import {
+ Button,
+ InputError,
+ NameType,
+ RawFileInput,
+ RawFileInputProps,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ _cs,
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+import { nonFieldError } from '@togglecorp/toggle-form';
+
+import Link from '#components/Link';
+import useAlert from '#hooks/useAlert';
+import { useLazyRequest } from '#utils/restRequest';
+import { transformObjectError } from '#utils/restRequest/error';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+export type SupportedPaths = '/api/v2/per-file/multiple/' | '/api/v2/dref-files/multiple/' | '/api/v2/flash-update-file/multiple/';
+
+interface FileUploadResult {
+ id: number;
+ file: string;
+}
+
+const keySelector = (d: FileUploadResult) => d.id;
+const valueSelector = (d: FileUploadResult) => d.file;
+
+function getFileNameFromUrl(urlString: string) {
+ const url = new URL(urlString);
+ const splits = url.pathname.split('/');
+ return splits[splits.length - 1];
+}
+
+export type Props = Omit, 'multiple' | 'value' | 'onChange' | 'children' | 'inputRef'> & {
+ actions?: React.ReactNode;
+ children?: React.ReactNode;
+ className?: string;
+ clearable?: boolean;
+ icons?: React.ReactNode;
+ onChange: (value: number[] | undefined, name: T) => void;
+ fileIdToUrlMap: Record;
+ setFileIdToUrlMap?: React.Dispatch>>;
+ url: SupportedPaths;
+ value: number[] | undefined | null;
+ variant?: ButtonVariant;
+ withoutPreview?: boolean;
+ error?: React.ReactNode;
+ description?: React.ReactNode;
+}
+
+function GoMultiFileInput(props: Props) {
+ const {
+ accept,
+ actions: actionsFromProps,
+ children,
+ className,
+ clearable,
+ disabled: disabledFromProps,
+ icons,
+ inputProps,
+ name,
+ onChange,
+ readOnly,
+ fileIdToUrlMap,
+ setFileIdToUrlMap,
+ url,
+ value,
+ variant = 'secondary',
+ withoutPreview,
+ error,
+ description,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const alert = useAlert();
+
+ const {
+ pending,
+ trigger: triggerFileUpload,
+ } = useLazyRequest({
+ formData: true,
+ url,
+ method: 'POST',
+ body: (body: { files: File[] }) => {
+ const formData = new FormData();
+
+ body.files.forEach((file) => {
+ formData.append('file', file);
+ });
+
+ // FIXME: fix typing in server (low priority)
+ // the server generated type for response and body is the same
+ return formData.getAll('file') as never;
+ },
+ onSuccess: (responseUnsafe) => {
+ // FIXME: fix typing in server (medium priority)
+ const response = responseUnsafe as unknown as FileUploadResult[];
+
+ const ids = response.map((val) => keySelector(val));
+
+ if (setFileIdToUrlMap) {
+ setFileIdToUrlMap((oldMap) => {
+ const newMap = {
+ ...oldMap,
+ };
+
+ response.forEach((val) => {
+ newMap[keySelector(val)] = valueSelector(val);
+ });
+
+ return newMap;
+ });
+ }
+ onChange([...(value ?? []), ...ids], name);
+ },
+ onFailure: ({
+ value: {
+ formErrors,
+ },
+ }) => {
+ const err = transformObjectError(formErrors, () => undefined);
+ // NOTE: could not use getErrorObject
+ const serverErrorMessage = err?.[nonFieldError] || (
+ typeof err?.file === 'object'
+ ? err[nonFieldError]
+ : err?.file
+ );
+ alert.show(
+ strings.goMultiFailedUploadMessage,
+ {
+ variant: 'danger',
+ description: serverErrorMessage,
+ },
+ );
+ },
+ });
+
+ const inputRef = useRef(null);
+
+ const handleChange = useCallback((files: File[] | undefined) => {
+ if (files) {
+ triggerFileUpload({ files });
+ }
+ }, [triggerFileUpload]);
+
+ const disabled = disabledFromProps || pending || readOnly;
+ const actions = (clearable && value && !readOnly && !disabled ? actionsFromProps : null);
+ const valueUrls = isDefined(value) ? (
+ value.map((fileId) => ({ id: fileId, url: fileIdToUrlMap?.[fileId] }))
+ ) : undefined;
+
+ const handleFileRemove = useCallback(
+ (id: number) => {
+ if (isNotDefined(value)) {
+ return;
+ }
+
+ const fileIndex = value.findIndex((fileId) => fileId === id);
+ if (fileIndex !== -1) {
+ const newValue = [...value];
+ newValue.splice(fileIndex, 1);
+ onChange(newValue, name);
+ }
+ },
+ [value, onChange, name],
+ );
+
+ return (
+
+
+ {children}
+
+ {!withoutPreview && isDefined(valueUrls) && valueUrls.length > 0 && (
+
+ {valueUrls.map(
+ (valueUrl) => (
+
+
+ {getFileNameFromUrl(valueUrl.url)}
+
+
+
+ ),
+ )}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ {error}
+
+
+ );
+}
+export default GoMultiFileInput;
diff --git a/src/components/domain/GoMultiFileInput/styles.module.css b/app/src/components/domain/GoMultiFileInput/styles.module.css
similarity index 100%
rename from src/components/domain/GoMultiFileInput/styles.module.css
rename to app/src/components/domain/GoMultiFileInput/styles.module.css
diff --git a/src/components/domain/GoSingleFileInput/i18n.json b/app/src/components/domain/GoSingleFileInput/i18n.json
similarity index 100%
rename from src/components/domain/GoSingleFileInput/i18n.json
rename to app/src/components/domain/GoSingleFileInput/i18n.json
diff --git a/app/src/components/domain/GoSingleFileInput/index.tsx b/app/src/components/domain/GoSingleFileInput/index.tsx
new file mode 100644
index 000000000..45a555e31
--- /dev/null
+++ b/app/src/components/domain/GoSingleFileInput/index.tsx
@@ -0,0 +1,186 @@
+import React, { useCallback } from 'react';
+import { DeleteBinLineIcon } from '@ifrc-go/icons';
+import {
+ ButtonVariant,
+ IconButton,
+ InputError,
+ type NameType,
+ RawFileInput,
+ type RawFileInputProps,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ _cs,
+ isDefined,
+} from '@togglecorp/fujs';
+import { nonFieldError } from '@togglecorp/toggle-form';
+
+import Link from '#components/Link';
+import useAlert from '#hooks/useAlert';
+import { useLazyRequest } from '#utils/restRequest';
+import { transformObjectError } from '#utils/restRequest/error';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+export type SupportedPaths = '/api/v2/per-file/' | '/api/v2/dref-files/' | '/api/v2/flash-update-file/';
+
+export type Props = Omit, 'multiple' | 'value' | 'onChange' | 'children'| 'inputRef'> & {
+ actions?: React.ReactNode;
+ children?: React.ReactNode;
+ className?: string;
+ clearable?: boolean;
+ icons?: React.ReactNode;
+ onChange: (value: number | undefined, name: T) => void;
+ fileIdToUrlMap: Record;
+ setFileIdToUrlMap?: React.Dispatch>>;
+ url: SupportedPaths;
+ value: number | undefined | null;
+ variant?: ButtonVariant;
+ withoutPreview?: boolean;
+ error?: React.ReactNode;
+ description?: React.ReactNode;
+}
+
+function GoSingleFileInput(props: Props) {
+ const {
+ accept,
+ actions: actionsFromProps,
+ children,
+ className,
+ clearable,
+ disabled: disabledFromProps,
+ icons,
+ inputProps,
+ name,
+ onChange,
+ readOnly,
+ fileIdToUrlMap,
+ setFileIdToUrlMap,
+ url,
+ value,
+ variant = 'secondary',
+ withoutPreview,
+ error,
+ description,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const alert = useAlert();
+
+ const {
+ pending,
+ trigger: triggerFileUpload,
+ } = useLazyRequest({
+ formData: true,
+ url,
+ method: 'POST',
+ // FIXME: fix typing in server (low priority)
+ // the server generated type for response and body is the same
+ body: (body: { file: File }) => body as never,
+ onSuccess: (response) => {
+ const { id, file } = response;
+ onChange(id, name);
+
+ if (isDefined(file) && setFileIdToUrlMap) {
+ setFileIdToUrlMap((oldMap) => {
+ const newMap = {
+ ...oldMap,
+ };
+ newMap[id] = file;
+ return newMap;
+ });
+ }
+ },
+ onFailure: ({
+ value: {
+ formErrors,
+ },
+ }) => {
+ const err = transformObjectError(formErrors, () => undefined);
+ // NOTE: could not use getErrorObject
+ const serverErrorMessage = err?.[nonFieldError] || (
+ typeof err?.file === 'object'
+ ? err[nonFieldError]
+ : err?.file
+ );
+ alert.show(
+ strings.failedUploadMessage,
+ {
+ variant: 'danger',
+ description: serverErrorMessage,
+ },
+ );
+ },
+ });
+
+ const handleChange = useCallback((file: File | undefined) => {
+ if (file) {
+ triggerFileUpload({ file });
+ }
+ }, [triggerFileUpload]);
+
+ const disabled = disabledFromProps || pending || readOnly;
+ const actions = (!readOnly && !disabled ? actionsFromProps : null);
+ const selectedFileUrl = isDefined(value) ? fileIdToUrlMap?.[value] : undefined;
+
+ const handleClearButtonClick = useCallback(() => {
+ onChange(undefined, name);
+ }, [onChange, name]);
+
+ return (
+
+
+
+ {children}
+
+ {clearable && value && (
+
+
+
+ )}
+
+ {!withoutPreview && isDefined(selectedFileUrl) ? (
+
+ {selectedFileUrl.split('/').pop()}
+
+ ) : (
+
+ {strings.noFileSelected}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ {error}
+
+
+ );
+}
+
+export default GoSingleFileInput;
diff --git a/src/components/domain/GoSingleFileInput/styles.module.css b/app/src/components/domain/GoSingleFileInput/styles.module.css
similarity index 100%
rename from src/components/domain/GoSingleFileInput/styles.module.css
rename to app/src/components/domain/GoSingleFileInput/styles.module.css
diff --git a/src/components/domain/HighlightedOperations/OperationCard/i18n.json b/app/src/components/domain/HighlightedOperations/OperationCard/i18n.json
similarity index 100%
rename from src/components/domain/HighlightedOperations/OperationCard/i18n.json
rename to app/src/components/domain/HighlightedOperations/OperationCard/i18n.json
diff --git a/app/src/components/domain/HighlightedOperations/OperationCard/index.tsx b/app/src/components/domain/HighlightedOperations/OperationCard/index.tsx
new file mode 100644
index 000000000..e25252c38
--- /dev/null
+++ b/app/src/components/domain/HighlightedOperations/OperationCard/index.tsx
@@ -0,0 +1,217 @@
+import { useContext } from 'react';
+import { FocusLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ Container,
+ KeyFigure,
+ NumberOutput,
+ TextOutput,
+ Tooltip,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ resolveToComponent,
+ sumSafe,
+} from '@ifrc-go/ui/utils';
+import {
+ _cs,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import SeverityIndicator from '#components/domain/SeverityIndicator';
+import Link from '#components/Link';
+import DomainContext from '#contexts/domain';
+import useAuth from '#hooks/domain/useAuth';
+import {
+ type GoApiResponse,
+ useLazyRequest,
+} from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type EventResponse = GoApiResponse<'/api/v2/event/'>;
+type EventListItem = NonNullable[number];
+
+// FIXME: move this to utils
+function getPercent(value: number | undefined, total: number | undefined) {
+ if (isNotDefined(value) || isNotDefined(total)) {
+ return undefined;
+ }
+ return (value / total) * 100;
+}
+
+interface Props {
+ className?: string;
+ data: EventListItem;
+ isSubscribed: boolean;
+}
+
+function OperationCard(props: Props) {
+ const {
+ className,
+ data: {
+ id,
+ name,
+ ifrc_severity_level,
+ ifrc_severity_level_display,
+ updated_at,
+ appeals,
+ countries = [],
+ },
+ isSubscribed = false,
+ } = props;
+
+ const { invalidate } = useContext(DomainContext);
+ const { isAuthenticated } = useAuth();
+
+ const {
+ pending: addSubscriptionPending,
+ trigger: triggerAddSubscription,
+ } = useLazyRequest({
+ url: '/api/v2/add_subscription/',
+ method: 'POST',
+ body: (eventId: number) => ([{
+ type: 'followedEvent',
+ value: eventId,
+ }]),
+ onSuccess: () => {
+ invalidate('user-me');
+ },
+ });
+
+ const {
+ pending: removeSubscriptionPending,
+ trigger: triggerRemoveSubscription,
+ } = useLazyRequest({
+ url: '/api/v2/del_subscription/',
+ method: 'POST',
+ body: (eventId: number) => ([{
+ value: eventId,
+ }]),
+ onSuccess: () => {
+ invalidate('user-me');
+ },
+ });
+
+ const subscriptionPending = addSubscriptionPending || removeSubscriptionPending;
+
+ const strings = useTranslation(i18n);
+ const targetedPopulation = sumSafe(appeals.map((appeal) => appeal.num_beneficiaries));
+ const amountRequested = sumSafe(appeals.map((appeal) => appeal.amount_requested));
+ const amountFunded = sumSafe(appeals.map((appeal) => appeal.amount_funded));
+
+ const coverage = getPercent(amountFunded, amountRequested);
+
+ const fundingCoverageDescription = resolveToComponent(
+ strings.operationCardFundingCoverage,
+ { coverage: },
+ );
+
+ let countriesInfoDisplay = strings.operationCardNoCountryInvolved;
+ if (countries.length === 1) {
+ countriesInfoDisplay = countries[0].name ?? '?';
+ } else if (countries.length > 1) {
+ countriesInfoDisplay = strings.operationCardInvolvesMultipleCountries;
+ }
+
+ return (
+
+ {name}
+
+ )}
+ headingLevel={4}
+ withInternalPadding
+ withHeaderBorder
+ withoutWrapInHeading
+ icons={ifrc_severity_level ? (
+ <>
+
+
+ )}
+ value={ifrc_severity_level_display}
+ withoutLabelColon
+ />
+ }
+ value={countriesInfoDisplay}
+ withoutLabelColon
+ />
+ >
+ )}
+ />
+
+ >
+ ) : undefined}
+ actions={isAuthenticated && (
+
+ )}
+ headerDescription={(
+
+ )}
+ childrenContainerClassName={styles.figures}
+ >
+
+ {strings.operationCardTargetedPopulation}
+
+ )}
+ compactValue
+ />
+
+ {/* FIXME This keyFigure should route to emergencies/id/report */}
+
+ {strings.operationCardFunding}
+
+ )}
+ compactValue
+ progress={coverage}
+ progressDescription={fundingCoverageDescription}
+ />
+
+ );
+}
+
+export default OperationCard;
diff --git a/src/components/domain/HighlightedOperations/OperationCard/styles.module.css b/app/src/components/domain/HighlightedOperations/OperationCard/styles.module.css
similarity index 100%
rename from src/components/domain/HighlightedOperations/OperationCard/styles.module.css
rename to app/src/components/domain/HighlightedOperations/OperationCard/styles.module.css
diff --git a/src/components/domain/HighlightedOperations/i18n.json b/app/src/components/domain/HighlightedOperations/i18n.json
similarity index 100%
rename from src/components/domain/HighlightedOperations/i18n.json
rename to app/src/components/domain/HighlightedOperations/i18n.json
diff --git a/app/src/components/domain/HighlightedOperations/index.tsx b/app/src/components/domain/HighlightedOperations/index.tsx
new file mode 100644
index 000000000..2dd585364
--- /dev/null
+++ b/app/src/components/domain/HighlightedOperations/index.tsx
@@ -0,0 +1,187 @@
+import {
+ useCallback,
+ useMemo,
+} from 'react';
+import {
+ Container,
+ Grid,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ isDefined,
+ listToMap,
+} from '@togglecorp/fujs';
+
+import Link from '#components/Link';
+import useUserMe from '#hooks/domain/useUserMe';
+import {
+ type GoApiResponse,
+ type GoApiUrlQuery,
+ useRequest,
+} from '#utils/restRequest';
+
+import OperationCard from './OperationCard';
+
+import i18n from './i18n.json';
+
+type EventQueryParams = GoApiUrlQuery<'/api/v2/event/'>;
+type EventResponse = GoApiResponse<'/api/v2/event/'>;
+type EventListItem = NonNullable[number];
+
+const keySelector = (event: EventListItem) => event.id;
+
+type BaseProps = {
+ className?: string;
+}
+
+type CountryProps = {
+ variant: 'country';
+ countryId: number;
+}
+
+type RegionProps = {
+ variant: 'region';
+ regionId: number;
+}
+
+type GlobalProps = {
+ variant: 'global';
+}
+
+type Props = BaseProps & (CountryProps | RegionProps | GlobalProps);
+
+function HighlightedOperations(props: Props) {
+ const {
+ className,
+ variant,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ // eslint-disable-next-line react/destructuring-assignment
+ const regionId = variant === 'region' ? props.regionId : undefined;
+
+ // eslint-disable-next-line react/destructuring-assignment
+ const countryId = variant === 'country' ? props.countryId : undefined;
+
+ const query = useMemo(
+ () => {
+ if (variant === 'global') {
+ return { is_featured: true };
+ }
+
+ if (variant === 'country') {
+ const thirtyDaysAgo = new Date();
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+ thirtyDaysAgo.setHours(0, 0, 0, 0);
+
+ return {
+ countries__in: countryId,
+ disaster_start_date__gte: thirtyDaysAgo.toISOString(),
+ ordering: '-disaster_start_date',
+ };
+ }
+
+ return {
+ is_featured_region: true,
+ regions__in: regionId,
+ };
+ },
+ [variant, regionId, countryId],
+ );
+
+ const {
+ error: featuredEmergencyResponseError,
+ pending: featuredEmergencyPending,
+ response: featuredEmergencyResponse,
+ } = useRequest({
+ url: '/api/v2/event/',
+ query,
+ });
+
+ const meResponse = useUserMe();
+
+ // FIXME: the subscription information should be sent from the server on
+ // the emergency
+ const subscriptionMap = listToMap(
+ meResponse?.subscription?.filter(
+ (sub) => isDefined(sub.event),
+ ) ?? [],
+ (sub) => sub.event ?? 'unknown',
+ () => true,
+ );
+
+ const rendererParams = useCallback(
+ (_: number, emergency: EventListItem) => {
+ const isSubscribed = subscriptionMap[emergency.id] ?? false;
+ return {
+ data: emergency,
+ isSubscribed,
+ };
+ },
+ [subscriptionMap],
+ );
+
+ const featuredEmergencies = featuredEmergencyResponse?.results;
+
+ const urlSearch = useMemo(
+ () => {
+ if (variant === 'country') {
+ return `country=${countryId}`;
+ }
+
+ if (variant === 'region') {
+ return `region=${regionId}`;
+ }
+
+ return undefined;
+ },
+ [regionId, countryId, variant],
+ );
+
+ const viewAllLabel = useMemo(
+ () => {
+ if (variant === 'country') {
+ return strings.highlightedOperationsViewAllInCountry;
+ }
+
+ if (variant === 'region') {
+ return strings.highlightedOperationsViewAllInRegion;
+ }
+
+ return strings.highlightedOperationsViewAll;
+ },
+ [variant, strings],
+ );
+
+ return (
+
+ {viewAllLabel}
+
+ )}
+ >
+
+
+ );
+}
+
+export default HighlightedOperations;
diff --git a/src/components/domain/HistoricalDataChart/i18n.json b/app/src/components/domain/HistoricalDataChart/i18n.json
similarity index 100%
rename from src/components/domain/HistoricalDataChart/i18n.json
rename to app/src/components/domain/HistoricalDataChart/i18n.json
diff --git a/app/src/components/domain/HistoricalDataChart/index.tsx b/app/src/components/domain/HistoricalDataChart/index.tsx
new file mode 100644
index 000000000..362d15689
--- /dev/null
+++ b/app/src/components/domain/HistoricalDataChart/index.tsx
@@ -0,0 +1,355 @@
+import { useRef } from 'react';
+import {
+ CycloneIcon,
+ DroughtIcon,
+ FloodIcon,
+ FoodSecurityIcon,
+} from '@ifrc-go/icons';
+import {
+ ChartAxes,
+ Container,
+ SelectInput,
+ TextOutput,
+ Tooltip,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ getPercentage,
+ numericIdSelector,
+ stringNameSelector,
+ sumSafe,
+} from '@ifrc-go/ui/utils';
+import {
+ isDefined,
+ isFalsyString,
+ isNotDefined,
+ unique,
+} from '@togglecorp/fujs';
+
+import useChartData from '#hooks/useChartData';
+import useInputState from '#hooks/useInputState';
+import {
+ COLOR_HAZARD_CYCLONE,
+ COLOR_HAZARD_DROUGHT,
+ COLOR_HAZARD_FLOOD,
+ COLOR_HAZARD_FOOD_INSECURITY,
+ defaultChartMargin,
+ defaultChartPadding,
+} from '#utils/constants';
+import type { GoApiResponse } from '#utils/restRequest';
+import { useRequest } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type GoHistoricalResponse = GoApiResponse<'/api/v2/go-historical/'>;
+type EventItem = NonNullable[number];
+type PartialDisasterType = EventItem['dtype'];
+
+type DisasterType = Omit & {
+ name: string;
+}
+
+// FIXME: how can we guarantee that these disaster type ids do not change
+// TODO: Add a flag in database to mark these disaster as risk hazards
+const DISASTER_FLOOD = 12;
+const DISASTER_FLASH_FLOOD = 27;
+const DISASTER_CYCLONE = 4;
+const DISASTER_FOOD_INSECURITY = 21;
+const DISASTER_DROUGHT = 20;
+
+const validDisastersForChart: Record = {
+ [DISASTER_FLOOD]: true,
+ [DISASTER_FLASH_FLOOD]: true,
+ [DISASTER_CYCLONE]: true,
+ [DISASTER_FOOD_INSECURITY]: true,
+ [DISASTER_DROUGHT]: true,
+};
+
+const X_AXIS_HEIGHT = 32;
+const Y_AXIS_WIDTH = 64;
+
+const chartOffset = {
+ left: Y_AXIS_WIDTH,
+ top: 0,
+ right: 0,
+ bottom: X_AXIS_HEIGHT,
+};
+
+const currentYear = new Date().getFullYear();
+const firstDayOfYear = new Date(currentYear, 0, 1);
+const lastDayOfYear = new Date(currentYear, 11, 31);
+
+function isValidDisaster(
+ disaster: PartialDisasterType | null | undefined,
+): disaster is DisasterType {
+ if (isNotDefined(disaster)) {
+ return false;
+ }
+ if (isFalsyString(disaster.name)) {
+ return false;
+ }
+ return validDisastersForChart[disaster.id];
+}
+
+const hazardIdToColorMap: Record = {
+ [DISASTER_FLOOD]: COLOR_HAZARD_FLOOD,
+ [DISASTER_FLASH_FLOOD]: COLOR_HAZARD_FLOOD,
+ [DISASTER_CYCLONE]: COLOR_HAZARD_CYCLONE,
+ [DISASTER_FOOD_INSECURITY]: COLOR_HAZARD_FOOD_INSECURITY,
+ [DISASTER_DROUGHT]: COLOR_HAZARD_DROUGHT,
+};
+
+const hazardIdToIconMap: Record = {
+ [DISASTER_FLOOD]: ,
+ [DISASTER_FLASH_FLOOD]: ,
+ [DISASTER_CYCLONE]: ,
+ [DISASTER_FOOD_INSECURITY]: ,
+ [DISASTER_DROUGHT]: ,
+};
+
+function getNumAffected(event: EventItem) {
+ if (isDefined(event.num_affected)) {
+ return event.num_affected;
+ }
+
+ return sumSafe(
+ event.appeals.map(
+ (appeal) => appeal.num_beneficiaries,
+ ),
+ );
+}
+
+type RegionProps = {
+ variant: 'region';
+ regionId: number;
+ countryId?: never;
+}
+
+type CountryProps = {
+ variant: 'country';
+ countryId: number;
+ regionId?: never;
+}
+
+type Props = RegionProps | CountryProps;
+
+function HistoricalDataChart(props: Props) {
+ const {
+ countryId,
+ regionId,
+ variant,
+ } = props;
+ const strings = useTranslation(i18n);
+
+ const [disasterFilter, setDisasterFilter] = useInputState(undefined);
+ const chartContainerRef = useRef(null);
+
+ const { response: historicalDataResponse } = useRequest({
+ skip: variant === 'country' ? isNotDefined(countryId) : isNotDefined(regionId),
+ url: '/api/v2/go-historical/',
+ query: variant === 'country' ? {
+ countries: [countryId],
+ } : { region: regionId },
+ });
+
+ const disasterOptions = unique(
+ historicalDataResponse?.results?.map(
+ (event) => {
+ if (!isValidDisaster(event.dtype)) {
+ return undefined;
+ }
+
+ return {
+ id: event.dtype.id,
+ name: event.dtype.name,
+ };
+ },
+ ).filter(isDefined) ?? [],
+ (option) => option.id,
+ );
+
+ const filteredEvents = historicalDataResponse?.results?.map(
+ (event) => {
+ if (!isValidDisaster(event.dtype)) {
+ return undefined;
+ }
+
+ if (isDefined(disasterFilter) && disasterFilter !== event.dtype.id) {
+ return undefined;
+ }
+
+ const numAffected = getNumAffected(event);
+
+ if (isNotDefined(numAffected)) {
+ return undefined;
+ }
+
+ return {
+ ...event,
+ dtype: event.dtype,
+ num_affected: numAffected,
+ };
+ },
+ ).filter(isDefined);
+
+ const {
+ dataPoints,
+ xAxisTicks,
+ yAxisTicks,
+ chartSize,
+ } = useChartData(
+ filteredEvents,
+ {
+ containerRef: chartContainerRef,
+ chartOffset,
+ chartMargin: defaultChartMargin,
+ chartPadding: defaultChartPadding,
+ keySelector: (datum) => datum.id,
+ xValueSelector: (datum) => {
+ const date = new Date(datum.disaster_start_date);
+ date.setFullYear(currentYear);
+ return date.getTime();
+ },
+ type: 'temporal',
+ xAxisLabelSelector: (timestamp) => (
+ new Date(timestamp).toLocaleString(
+ navigator.language,
+ { month: 'short' },
+ )
+ ),
+ yValueSelector: (datum) => datum.num_affected,
+ xDomain: {
+ min: firstDayOfYear.getTime(),
+ max: lastDayOfYear.getTime(),
+ },
+ },
+ );
+
+ return (
+
+ )}
+ >
+
+
+ {dataPoints?.map(
+ (point) => {
+ const funded = sumSafe(
+ point.originalData.appeals.map(
+ (appeal) => appeal.amount_funded,
+ ),
+ ) ?? 0;
+ const requested = sumSafe(
+ point.originalData.appeals.map(
+ (appeal) => appeal.amount_requested,
+ ),
+ ) ?? 0;
+
+ const coverage = requested === 0
+ ? undefined
+ : getPercentage(funded, requested);
+
+ return (
+
+ {hazardIdToIconMap[point.originalData.dtype.id]}
+
+
+
+
+
+ >
+ )}
+ />
+
+ );
+ },
+ )}
+
+
+ {disasterOptions.map(
+ (disaster) => (
+
+
+ {hazardIdToIconMap[disaster.id]}
+
+
+ {disaster.name}
+
+
+ ),
+ )}
+
+
+ );
+}
+
+export default HistoricalDataChart;
diff --git a/src/components/domain/HistoricalDataChart/styles.module.css b/app/src/components/domain/HistoricalDataChart/styles.module.css
similarity index 100%
rename from src/components/domain/HistoricalDataChart/styles.module.css
rename to app/src/components/domain/HistoricalDataChart/styles.module.css
diff --git a/src/components/domain/ImageWithCaptionInput/i18n.json b/app/src/components/domain/ImageWithCaptionInput/i18n.json
similarity index 100%
rename from src/components/domain/ImageWithCaptionInput/i18n.json
rename to app/src/components/domain/ImageWithCaptionInput/i18n.json
diff --git a/app/src/components/domain/ImageWithCaptionInput/index.tsx b/app/src/components/domain/ImageWithCaptionInput/index.tsx
new file mode 100644
index 000000000..d55aa5e2d
--- /dev/null
+++ b/app/src/components/domain/ImageWithCaptionInput/index.tsx
@@ -0,0 +1,129 @@
+import { useCallback } from 'react';
+import { TextInput } from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ _cs,
+ isDefined,
+ randomString,
+} from '@togglecorp/fujs';
+import {
+ getErrorObject,
+ type ObjectError,
+ type SetValueArg,
+ useFormObject,
+} from '@togglecorp/toggle-form';
+
+import GoSingleFileInput, { type SupportedPaths } from '#components/domain/GoSingleFileInput';
+import NonFieldError from '#components/NonFieldError';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type Value = {
+ id?: number | undefined;
+ client_id: string;
+ caption?: string | undefined;
+};
+
+interface Props {
+ className?: string;
+ name: N;
+ url: SupportedPaths;
+ value: Value | null | undefined;
+ onChange: (value: SetValueArg | undefined, name: N) => void;
+ error: ObjectError | undefined;
+ fileIdToUrlMap: Record;
+ setFileIdToUrlMap?: React.Dispatch>>;
+ label: React.ReactNode;
+ icons?: React.ReactNode;
+ actions?: React.ReactNode;
+ disabled?: boolean;
+}
+
+// FIXME: Move this to components
+function ImageWithCaptionInput(props: Props) {
+ const {
+ className,
+ name,
+ value,
+ url,
+ fileIdToUrlMap,
+ setFileIdToUrlMap,
+ onChange,
+ error: formError,
+ label,
+ icons,
+ actions,
+ disabled,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const setFieldValue = useFormObject(
+ name,
+ onChange,
+ () => ({ client_id: randomString() }),
+ );
+
+ const error = getErrorObject(formError);
+
+ const fileUrl = isDefined(value) && isDefined(value.id)
+ ? fileIdToUrlMap[value.id]
+ : undefined;
+
+ const handleFileInputChange = useCallback((newFileId: number | undefined) => {
+ if (!newFileId) {
+ onChange(undefined, name);
+ } else {
+ setFieldValue(newFileId, 'id');
+ }
+ }, [
+ setFieldValue,
+ onChange,
+ name,
+ ]);
+
+ return (
+
+
+
+ ) : undefined}
+ clearable
+ >
+ {label}
+
+ {value?.id && (
+
+ )}
+
+ );
+}
+
+export default ImageWithCaptionInput;
diff --git a/src/components/domain/ImageWithCaptionInput/styles.module.css b/app/src/components/domain/ImageWithCaptionInput/styles.module.css
similarity index 100%
rename from src/components/domain/ImageWithCaptionInput/styles.module.css
rename to app/src/components/domain/ImageWithCaptionInput/styles.module.css
diff --git a/src/components/domain/KeywordSearchSelectInput/i18n.json b/app/src/components/domain/KeywordSearchSelectInput/i18n.json
similarity index 100%
rename from src/components/domain/KeywordSearchSelectInput/i18n.json
rename to app/src/components/domain/KeywordSearchSelectInput/i18n.json
diff --git a/app/src/components/domain/KeywordSearchSelectInput/index.tsx b/app/src/components/domain/KeywordSearchSelectInput/index.tsx
new file mode 100644
index 000000000..b6d47b61a
--- /dev/null
+++ b/app/src/components/domain/KeywordSearchSelectInput/index.tsx
@@ -0,0 +1,323 @@
+import {
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import { SearchLineIcon } from '@ifrc-go/icons';
+import { SearchSelectInput } from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { sumSafe } from '@ifrc-go/ui/utils';
+import {
+ compareNumber,
+ isDefined,
+ isNotDefined,
+ mapToList,
+} from '@togglecorp/fujs';
+
+import useDebouncedValue from '#hooks/useDebouncedValue';
+import useRouting from '#hooks/useRouting';
+import { defaultRanking } from '#utils/common';
+import { KEY_URL_SEARCH } from '#utils/constants';
+import {
+ type GoApiResponse,
+ type GoApiUrlQuery,
+ useRequest,
+} from '#utils/restRequest';
+
+import { type WrappedRoutes } from '../../../App/routes';
+
+import i18n from './i18n.json';
+
+type GetSearchParams = GoApiUrlQuery<'/api/v1/search/'>;
+type SearchResponse = GoApiResponse<'/api/v1/search/'>;
+type SearchResponseKeys = NonNullable;
+
+type SearchItem = {
+ id: number;
+ name: string;
+ type: SearchResponseKeys;
+ score: number;
+ pk: string;
+}
+
+interface Route {
+ route: keyof WrappedRoutes,
+ routeParams: string;
+}
+
+function keySelector(d: SearchItem) {
+ return d.pk;
+}
+
+function labelSelector(d: SearchItem) {
+ return d.name;
+}
+
+const searchTypeToRouteMap: Record = {
+ regions: {
+ route: 'regionsLayout',
+ routeParams: 'regionId',
+ },
+ countries: {
+ route: 'countriesLayout',
+ routeParams: 'countryId',
+ },
+ district_province_response: {
+ route: 'countriesLayout',
+ routeParams: 'countryId',
+ },
+ emergencies: {
+ route: 'emergenciesLayout',
+ routeParams: 'emergencyId',
+ },
+ reports: {
+ route: 'fieldReportDetails',
+ routeParams: 'fieldReportId',
+ },
+ projects: {
+ route: 'threeWProjectDetail',
+ routeParams: 'projectId',
+ },
+ rapid_response_deployments: {
+ route: 'emergenciesLayout',
+ routeParams: 'emergencyId',
+ },
+ surge_alerts: {
+ route: 'emergenciesLayout',
+ routeParams: 'emergencyId',
+ },
+ surge_deployments: {
+ route: 'emergenciesLayout',
+ routeParams: 'emergencyId',
+ },
+};
+
+function KeywordSearchSelectInput() {
+ const [opened, setOpened] = useState(false);
+ const [searchText, setSearchText] = useState(undefined);
+ const debouncedSearchText = useDebouncedValue(searchText);
+ const { navigate } = useRouting();
+ const strings = useTranslation(i18n);
+
+ const searchTypeToLabelMap: Record = useMemo(() => ({
+ countries: strings.country,
+ district_province_response: strings.district,
+ regions: strings.region,
+ surge_deployments: strings.surgeDeployment,
+ surge_alerts: strings.surgeAlert,
+ rapid_response_deployments: strings.rrDeployment,
+ emergencies: strings.emergency,
+ projects: strings.project,
+ reports: strings.report,
+ }), [
+ strings.country,
+ strings.district,
+ strings.region,
+ strings.surgeDeployment,
+ strings.surgeAlert,
+ strings.rrDeployment,
+ strings.emergency,
+ strings.project,
+ strings.report,
+ ]);
+
+ const descriptionSelector = useCallback((d: SearchItem) => (
+ searchTypeToLabelMap[d.type]
+ ), [searchTypeToLabelMap]);
+
+ const trimmedSearchText = debouncedSearchText?.trim();
+
+ const query: GetSearchParams | undefined = trimmedSearchText ? {
+ keyword: trimmedSearchText,
+ } : undefined;
+
+ const {
+ pending,
+ response,
+ } = useRequest({
+ skip: !opened || isNotDefined(trimmedSearchText) || trimmedSearchText.length === 0,
+ url: '/api/v1/search/',
+ query,
+ preserveResponse: true,
+ });
+
+ const rankedSearchResponseKeys = useMemo(
+ () => {
+ const searchResponseKeys = Object.keys(response ?? {}) as SearchResponseKeys[];
+
+ function getAverageScore(
+ results: { score: number | null | undefined }[] | undefined | null,
+ ) {
+ const scoreList = results?.map((result) => result.score);
+ if (isNotDefined(scoreList) || scoreList.length === 0) {
+ return 0;
+ }
+
+ const totalScore = sumSafe(scoreList) ?? 0;
+ return totalScore / scoreList.length;
+ }
+
+ function compareStaticKey(a: string, b: string, direction?: number) {
+ const staticKeyRanking: Record = {
+ regions: 1,
+ countries: 2,
+ district_province_response: 3,
+ };
+
+ return compareNumber(
+ staticKeyRanking[a] ?? 4,
+ staticKeyRanking[b] ?? 4,
+ direction,
+ );
+ }
+
+ searchResponseKeys.sort(
+ (a, b) => {
+ const aScore = getAverageScore(response?.[a]) ?? 0;
+ const bScore = getAverageScore(response?.[b]) ?? 0;
+
+ const aDefaultRank = defaultRanking[a];
+ const bDefaultRank = defaultRanking[b];
+
+ return compareStaticKey(a, b)
+ || compareNumber(aScore, bScore, -1)
+ || compareNumber(aDefaultRank, bDefaultRank, -1);
+ },
+ );
+ return searchResponseKeys;
+ },
+ [response],
+ );
+
+ const sortByRankedKeys = useCallback((a: SearchItem, b: SearchItem) => {
+ const indexA = rankedSearchResponseKeys.indexOf(a.type);
+ const indexB = rankedSearchResponseKeys.indexOf(b.type);
+
+ return compareNumber(indexA, indexB);
+ }, [rankedSearchResponseKeys]);
+
+ const options = useMemo(
+ (): SearchItem[] => {
+ if (isNotDefined(response)) {
+ return [];
+ }
+ const {
+ surge_alerts,
+ surge_deployments,
+ rapid_response_deployments,
+ district_province_response,
+ ...others
+ } = response;
+
+ const results = mapToList(
+ (others),
+ (value, key) => (
+ value?.map((val) => ({
+ id: val.id,
+ name: val.name,
+ type: key as keyof SearchResponse,
+ pk: `${val.id}-${key}`,
+ score: val.score,
+ }))
+ ),
+ )?.flat().filter(isDefined);
+
+ const surgeResults = mapToList(
+ {
+ surge_alerts,
+ surge_deployments,
+ rapid_response_deployments,
+ },
+ (value, key) => (
+ value?.map((val) => ({
+ id: val.event_id,
+ name: val.event_name,
+ type: key as keyof SearchResponse,
+ pk: `${val.id}-${key}`,
+ score: val.score,
+ }))
+ ),
+ )?.flat().filter(isDefined);
+
+ const districtProvinceResults = mapToList(
+ {
+ district_province_response,
+ },
+ (value, key) => (
+ value?.map((val) => ({
+ id: val.country_id,
+ name: val.name,
+ type: key as keyof SearchResponse,
+ pk: `${val.id}-${key}`,
+ score: val.score,
+ }))
+ ),
+ )?.flat().filter(isDefined);
+
+ return [
+ ...(results ?? []),
+ ...(surgeResults ?? []),
+ ...(districtProvinceResults ?? []),
+ ].sort(sortByRankedKeys);
+ },
+ [response, sortByRankedKeys],
+ );
+
+ const handleOptionSelect = useCallback((
+ _: string | undefined,
+ __: string,
+ option: SearchItem | undefined,
+ ) => {
+ if (!option) {
+ return;
+ }
+
+ const route = searchTypeToRouteMap[option.type];
+ navigate(
+ route.route,
+ {
+ params: {
+ [route.routeParams]: option.id,
+ },
+ },
+ );
+ }, [navigate]);
+
+ const handleSearchInputEnter = useCallback((text: string | undefined) => {
+ // NOTE: We are not deliberately not using debouncedSearchText here
+ const searchStringSafe = text?.trim() ?? '';
+ if (searchStringSafe.length > 0) {
+ navigate(
+ 'search',
+ { search: `${KEY_URL_SEARCH}=${text}` },
+ );
+ }
+ }, [
+ navigate,
+ ]);
+
+ return (
+ }
+ selectedOnTop={false}
+ onEnterWithoutOption={handleSearchInputEnter}
+ />
+ );
+}
+
+export default KeywordSearchSelectInput;
diff --git a/src/components/domain/LanguageMismatchMessage/i18n.json b/app/src/components/domain/LanguageMismatchMessage/i18n.json
similarity index 100%
rename from src/components/domain/LanguageMismatchMessage/i18n.json
rename to app/src/components/domain/LanguageMismatchMessage/i18n.json
diff --git a/app/src/components/domain/LanguageMismatchMessage/index.tsx b/app/src/components/domain/LanguageMismatchMessage/index.tsx
new file mode 100644
index 000000000..bdb95ec9d
--- /dev/null
+++ b/app/src/components/domain/LanguageMismatchMessage/index.tsx
@@ -0,0 +1,49 @@
+import { Message } from '@ifrc-go/ui';
+import { Language } from '@ifrc-go/ui/contexts';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ languageNameMapEn,
+ resolveToString,
+} from '@ifrc-go/ui/utils';
+
+import i18n from './i18n.json';
+
+interface Props {
+ title?: React.ReactNode;
+
+ // FIXME: typings should be fixed in the server
+ // this should be of type Language
+ originalLanguage: string;
+}
+
+function LanguageMismatchMessage(props: Props) {
+ const strings = useTranslation(i18n);
+
+ const {
+ title = strings.languageMismatchErrorTitle,
+ originalLanguage,
+ } = props;
+
+ return (
+
+ );
+}
+
+export default LanguageMismatchMessage;
diff --git a/src/components/domain/MultiImageWithCaptionInput/i18n.json b/app/src/components/domain/MultiImageWithCaptionInput/i18n.json
similarity index 100%
rename from src/components/domain/MultiImageWithCaptionInput/i18n.json
rename to app/src/components/domain/MultiImageWithCaptionInput/i18n.json
diff --git a/app/src/components/domain/MultiImageWithCaptionInput/index.tsx b/app/src/components/domain/MultiImageWithCaptionInput/index.tsx
new file mode 100644
index 000000000..e40517878
--- /dev/null
+++ b/app/src/components/domain/MultiImageWithCaptionInput/index.tsx
@@ -0,0 +1,201 @@
+import {
+ useCallback,
+ useMemo,
+} from 'react';
+import { DeleteBinLineIcon } from '@ifrc-go/icons';
+import {
+ IconButton,
+ TextInput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ _cs,
+ isDefined,
+ isNotDefined,
+ randomString,
+} from '@togglecorp/fujs';
+import {
+ type ArrayError,
+ getErrorObject,
+ type SetValueArg,
+ useFormArray,
+} from '@togglecorp/toggle-form';
+
+import GoMultiFileInput, { type SupportedPaths } from '#components/domain/GoMultiFileInput';
+import NonFieldError from '#components/NonFieldError';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type Value = {
+ client_id: string;
+ id?: number;
+ caption?: string;
+};
+
+interface Props {
+ className?: string;
+ name: N;
+ url: SupportedPaths;
+ value: Value[] | null | undefined;
+ onChange: (value: SetValueArg, name: N) => void;
+ error: ArrayError | undefined;
+ fileIdToUrlMap: Record;
+ setFileIdToUrlMap?: React.Dispatch>>;
+ label: React.ReactNode;
+ icons?: React.ReactNode;
+ actions?: React.ReactNode;
+ disabled?: boolean;
+}
+
+// FIXME: Move this to components
+function MultiImageWithCaptionInput(props: Props) {
+ const {
+ className,
+ name,
+ value,
+ url,
+ fileIdToUrlMap,
+ setFileIdToUrlMap,
+ onChange,
+ error: formError,
+ label,
+ icons,
+ actions,
+ disabled,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const error = getErrorObject(formError);
+
+ const {
+ setValue: setFieldValue,
+ removeValue,
+ } = useFormArray(name, onChange);
+
+ const handleFileInputChange = useCallback(
+ (newValue: number[] | undefined) => {
+ if (isDefined(newValue)) {
+ newValue.forEach(
+ (fileId: number, index: number) => {
+ const oldValue = value?.[index];
+
+ if (isNotDefined(oldValue)) {
+ setFieldValue(
+ {
+ client_id: String(fileId),
+ id: fileId,
+ },
+ index,
+ );
+ }
+ },
+ );
+ }
+ },
+ [value, setFieldValue],
+ );
+
+ const handleCaptionChange = useCallback(
+ (newValue: string | undefined, index: number) => {
+ setFieldValue(
+ (prevValue) => {
+ if (isNotDefined(prevValue)) {
+ return {
+ client_id: randomString(),
+ caption: newValue,
+ };
+ }
+
+ return {
+ ...prevValue,
+ caption: newValue,
+ };
+ },
+ index,
+ );
+ },
+ [setFieldValue],
+ );
+
+ const fileInputValue = useMemo(() => (
+ value
+ ?.map((fileValue) => fileValue.id)
+ .filter(isDefined)
+ ), [value]);
+
+ return (
+
+
+
+ {label}
+
+ {value && value.length > 0 && (
+
+ {value?.map((fileValue, index) => {
+ // NOTE: Not sure why this is here, need to
+ // talk with @frozenhelium
+ if (isNotDefined(fileValue.id)) {
+ return null;
+ }
+
+ const imageError = getErrorObject(error?.[fileValue.client_id]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+export default MultiImageWithCaptionInput;
diff --git a/src/components/domain/MultiImageWithCaptionInput/styles.module.css b/app/src/components/domain/MultiImageWithCaptionInput/styles.module.css
similarity index 100%
rename from src/components/domain/MultiImageWithCaptionInput/styles.module.css
rename to app/src/components/domain/MultiImageWithCaptionInput/styles.module.css
diff --git a/src/components/domain/NationalSocietyMultiSelectInput.tsx b/app/src/components/domain/NationalSocietyMultiSelectInput.tsx
similarity index 86%
rename from src/components/domain/NationalSocietyMultiSelectInput.tsx
rename to app/src/components/domain/NationalSocietyMultiSelectInput.tsx
index 94bec889f..6fac81eab 100644
--- a/src/components/domain/NationalSocietyMultiSelectInput.tsx
+++ b/app/src/components/domain/NationalSocietyMultiSelectInput.tsx
@@ -1,6 +1,9 @@
-import type { MultiSelectInputProps } from '#components/MultiSelectInput';
-import MultiSelectInput from '#components/MultiSelectInput';
-import { numericIdSelector } from '#utils/selectors';
+import {
+ MultiSelectInput,
+ MultiSelectInputProps,
+} from '@ifrc-go/ui';
+import { numericIdSelector } from '@ifrc-go/ui/utils';
+
import useNationalSociety, { NationalSociety } from '#hooks/domain/useNationalSociety';
function countrySocietyNameSelector(country: NationalSociety) {
diff --git a/src/components/domain/NationalSocietySelectInput.tsx b/app/src/components/domain/NationalSocietySelectInput.tsx
similarity index 90%
rename from src/components/domain/NationalSocietySelectInput.tsx
rename to app/src/components/domain/NationalSocietySelectInput.tsx
index faf8c61e9..74a54ab73 100644
--- a/src/components/domain/NationalSocietySelectInput.tsx
+++ b/app/src/components/domain/NationalSocietySelectInput.tsx
@@ -1,8 +1,10 @@
+import {
+ SelectInput,
+ SelectInputProps,
+} from '@ifrc-go/ui';
+import { numericIdSelector } from '@ifrc-go/ui/utils';
import { isDefined } from '@togglecorp/fujs';
-import type { Props as SelectInputProps } from '#components/SelectInput';
-import SelectInput from '#components/SelectInput';
-import { numericIdSelector } from '#utils/selectors';
import useNationalSociety, { NationalSociety } from '#hooks/domain/useNationalSociety';
function countrySocietyNameSelector(country: NationalSociety) {
diff --git a/src/components/domain/NonEnglishFormCreationMessage/i18n.json b/app/src/components/domain/NonEnglishFormCreationMessage/i18n.json
similarity index 100%
rename from src/components/domain/NonEnglishFormCreationMessage/i18n.json
rename to app/src/components/domain/NonEnglishFormCreationMessage/i18n.json
diff --git a/app/src/components/domain/NonEnglishFormCreationMessage/index.tsx b/app/src/components/domain/NonEnglishFormCreationMessage/index.tsx
new file mode 100644
index 000000000..2ddb6fd7d
--- /dev/null
+++ b/app/src/components/domain/NonEnglishFormCreationMessage/index.tsx
@@ -0,0 +1,24 @@
+import { Message } from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import i18n from './i18n.json';
+
+interface Props {
+ title?: React.ReactNode;
+}
+
+function NonEnglishFormCreationMessage(props: Props) {
+ const strings = useTranslation(i18n);
+ const { title = strings.formNonEnglishErrorTitle } = props;
+
+ return (
+
+ );
+}
+
+export default NonEnglishFormCreationMessage;
diff --git a/src/components/domain/OperationListItem/i18n.json b/app/src/components/domain/OperationListItem/i18n.json
similarity index 100%
rename from src/components/domain/OperationListItem/i18n.json
rename to app/src/components/domain/OperationListItem/i18n.json
diff --git a/app/src/components/domain/OperationListItem/index.tsx b/app/src/components/domain/OperationListItem/index.tsx
new file mode 100644
index 000000000..e9937ce2c
--- /dev/null
+++ b/app/src/components/domain/OperationListItem/index.tsx
@@ -0,0 +1,86 @@
+import {
+ Button,
+ Header,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import {
+ type GoApiResponse,
+ useLazyRequest,
+} from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type EventGet = GoApiResponse<'/api/v2/event/'>;
+type EventResponseItem = NonNullable[number];
+
+export interface Props {
+ className?: string;
+ eventItem: EventResponseItem;
+ updateSubscibedEvents: () => void;
+ isLastItem: boolean;
+}
+
+function OperationInfoCard(props: Props) {
+ const {
+ className,
+ eventItem: {
+ id,
+ name,
+ updated_at,
+ },
+ updateSubscibedEvents: updateSubscribedEvents,
+ isLastItem,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const {
+ pending: removeSubscriptionPending,
+ trigger: triggerRemoveSubscription,
+ } = useLazyRequest({
+ method: 'POST',
+ body: (eventId: number) => ([{
+ value: eventId,
+ }]),
+ url: '/api/v2/del_subscription/',
+ onSuccess: updateSubscribedEvents,
+ });
+
+ const subscriptionPending = removeSubscriptionPending;
+
+ return (
+ <>
+
+ {strings.operationUnfollowButtonLabel}
+
+ )}
+ >
+
+
+ {!isLastItem && (
+
+ )}
+ >
+ );
+}
+
+export default OperationInfoCard;
diff --git a/src/components/domain/OperationListItem/styles.module.css b/app/src/components/domain/OperationListItem/styles.module.css
similarity index 100%
rename from src/components/domain/OperationListItem/styles.module.css
rename to app/src/components/domain/OperationListItem/styles.module.css
diff --git a/src/components/domain/PerAssessmentSummary/i18n.json b/app/src/components/domain/PerAssessmentSummary/i18n.json
similarity index 100%
rename from src/components/domain/PerAssessmentSummary/i18n.json
rename to app/src/components/domain/PerAssessmentSummary/i18n.json
diff --git a/app/src/components/domain/PerAssessmentSummary/index.tsx b/app/src/components/domain/PerAssessmentSummary/index.tsx
new file mode 100644
index 000000000..6ed063d14
--- /dev/null
+++ b/app/src/components/domain/PerAssessmentSummary/index.tsx
@@ -0,0 +1,274 @@
+import {
+ ExpandableContainer,
+ NumberOutput,
+ ProgressBar,
+ StackedProgressBar,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ getPercentage,
+ resolveToString,
+ sumSafe,
+} from '@ifrc-go/ui/utils';
+import {
+ _cs,
+ compareNumber,
+ isDefined,
+ isNotDefined,
+ listToGroupList,
+ listToMap,
+ mapToList,
+} from '@togglecorp/fujs';
+import { PartialForm } from '@togglecorp/toggle-form';
+
+import { type GoApiResponse } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type PerOptionsResponse = GoApiResponse<'/api/v2/per-options/'>;
+type AssessmentRequestBody = GoApiResponse<'/api/v2/per-assessment/{id}/', 'PATCH'>;
+
+export type PartialAssessment = PartialForm<
+ AssessmentRequestBody,
+ 'area' | 'component' | 'question'
+>;
+
+interface Props {
+ className?: string;
+ perOptionsResponse: PerOptionsResponse | undefined;
+ areaResponses: PartialAssessment['area_responses'] | undefined;
+ totalQuestionCount: number | undefined;
+ areaIdToTitleMap: Record;
+}
+
+const colors = [
+ 'var(--go-ui-color-red-90)',
+ 'var(--go-ui-color-red-70)',
+ 'var(--go-ui-color-red-50)',
+ 'var(--go-ui-color-red-40)',
+ 'var(--go-ui-color-red-20)',
+ 'var(--go-ui-color-red-10)',
+];
+
+function PerAssessmentSummary(props: Props) {
+ const {
+ className,
+ perOptionsResponse,
+ areaResponses,
+ totalQuestionCount,
+ areaIdToTitleMap,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const ratingIdToTitleMap = listToMap(
+ perOptionsResponse?.componentratings,
+ (rating) => rating.id,
+ (rating) => rating.title,
+ );
+
+ const ratingIdToValueMap = listToMap(
+ perOptionsResponse?.componentratings,
+ (rating) => rating.id,
+ (rating) => rating.value,
+ );
+
+ const answerIdToLabelMap = listToMap(
+ perOptionsResponse?.answers,
+ (answer) => answer.id,
+ (answer) => answer.text,
+ );
+
+ const allAnsweredResponses = areaResponses?.map(
+ (areaResponse) => areaResponse?.component_responses?.map(
+ (componentResponse) => componentResponse.question_responses,
+ ),
+ ).flat(2).filter(
+ (questionResponse) => isDefined(questionResponse?.answer),
+ );
+
+ const groupedResponses = listToGroupList(
+ allAnsweredResponses ?? [],
+ (response) => response?.answer ?? -1,
+ );
+
+ const groupedResponseList = mapToList(
+ groupedResponses,
+ (responses, answer) => {
+ if (isNotDefined(answer)) {
+ return undefined;
+ }
+
+ const numericAnswer = Number(answer);
+
+ if (numericAnswer === -1) {
+ return undefined;
+ }
+
+ return {
+ answer: numericAnswer,
+ responses,
+ answerDisplay: answerIdToLabelMap?.[Number(answer)],
+ };
+ },
+ ).filter(isDefined);
+
+ const allAnsweredComponents = areaResponses?.map(
+ (areaResponse) => areaResponse?.component_responses,
+ ).flat().filter((component) => isDefined(component?.rating));
+
+ const ratingGroupedComponents = listToGroupList(
+ allAnsweredComponents,
+ (component) => component?.rating ?? 0,
+ );
+
+ const statusGroupedComponentList = mapToList(
+ ratingGroupedComponents,
+ (components, rating) => ({
+ ratingId: Number(rating),
+ ratingValue: ratingIdToValueMap?.[Number(rating)],
+ ratingDisplay: ratingIdToTitleMap?.[Number(rating)],
+ components,
+ }),
+ )?.sort(
+ (a, b) => compareNumber(a.ratingValue, b.ratingValue, -1),
+ );
+
+ const averageRatingByAreaMap = listToMap(
+ areaResponses ?? [],
+ (areaResponse) => areaResponse?.area ?? -1,
+ (areaResponse) => {
+ // NOTE: do we take the average of only rated components or of all the
+ // components?
+ const filteredComponents = areaResponse?.component_responses?.filter(
+ (component) => isDefined(component?.rating_details)
+ && isDefined(component.rating_details.value)
+ && component.rating_details?.value !== 0,
+ ) ?? [];
+
+ if (filteredComponents.length === 0) {
+ return 0;
+ }
+
+ const ratings = filteredComponents.map(
+ (component) => (
+ isDefined(component.rating)
+ ? ratingIdToValueMap?.[component.rating]
+ : 0
+ ),
+ );
+
+ const ratingSum = sumSafe(ratings) ?? 0;
+ const avgRating = ratingSum / filteredComponents.length;
+
+ return avgRating;
+ },
+ );
+
+ const averageRatingByAreaList = mapToList(
+ areaIdToTitleMap,
+ (_, areaId) => ({
+ areaId,
+ rating: averageRatingByAreaMap[Number(areaId)] ?? 0,
+ areaDisplay: resolveToString(
+ strings.multiImageArea,
+ { areaId },
+ ),
+ }),
+ );
+
+ const description = isDefined(allAnsweredResponses) && isDefined(totalQuestionCount)
+ ? `${allAnsweredResponses?.length ?? 0} / ${resolveToString(
+ strings.benchmarksAssessed,
+ { totalQuestionCount },
+ )}`
+ : undefined;
+
+ // NOTE: We need to discuss UI of this component
+ return (
+
+
+
+ {groupedResponseList.map(
+ (groupedResponse) => (
+
+ ),
+ )}
+
+ )}
+ />
+
+ (
+ statusGroupedComponent.components.length
+ )
+ }
+ // FIXME: don't use inline selectors
+ colorSelector={(_, i) => colors[i]}
+ // FIXME: don't use inline selectors
+ labelSelector={
+ (statusGroupedComponent) => `${statusGroupedComponent.ratingValue}-${statusGroupedComponent.ratingDisplay}`
+ }
+ />
+
+ {averageRatingByAreaList.map(
+ (rating) => (
+
+
+
+
+ {rating.areaDisplay}
+
+
+ ),
+ )}
+
+
+ );
+}
+
+export default PerAssessmentSummary;
diff --git a/src/components/domain/PerAssessmentSummary/styles.module.css b/app/src/components/domain/PerAssessmentSummary/styles.module.css
similarity index 100%
rename from src/components/domain/PerAssessmentSummary/styles.module.css
rename to app/src/components/domain/PerAssessmentSummary/styles.module.css
diff --git a/src/components/domain/ProjectActions/i18n.json b/app/src/components/domain/ProjectActions/i18n.json
similarity index 100%
rename from src/components/domain/ProjectActions/i18n.json
rename to app/src/components/domain/ProjectActions/i18n.json
diff --git a/app/src/components/domain/ProjectActions/index.tsx b/app/src/components/domain/ProjectActions/index.tsx
new file mode 100644
index 000000000..d1a323c0d
--- /dev/null
+++ b/app/src/components/domain/ProjectActions/index.tsx
@@ -0,0 +1,125 @@
+import {
+ CopyLineIcon,
+ DeleteBinLineIcon,
+ HistoryLineIcon,
+ MoreFillIcon,
+ PencilFillIcon,
+ SearchLineIcon,
+} from '@ifrc-go/icons';
+import {
+ BlockLoading,
+ ConfirmButton,
+ DropdownMenu,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { resolveToString } from '@ifrc-go/ui/utils';
+
+import DropdownMenuItem from '#components/DropdownMenuItem';
+import { adminUrl } from '#config';
+import useAlert from '#hooks/useAlert';
+import { resolveUrl } from '#utils/resolveUrl';
+import {
+ type GoApiResponse,
+ useLazyRequest,
+} from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type Project = NonNullable['results']>[number];
+
+export interface Props {
+ className?: string;
+ onProjectDeletionSuccess: () => void;
+ project: Project;
+}
+
+function ProjectActions(props: Props) {
+ const strings = useTranslation(i18n);
+ const alert = useAlert();
+
+ const {
+ className,
+ project,
+ onProjectDeletionSuccess,
+ } = props;
+
+ const {
+ pending: projectDeletionPending,
+ trigger: requestProjectDeletion,
+ } = useLazyRequest({
+ url: '/api/v2/project/{id}/',
+ method: 'DELETE',
+ pathVariables: { id: project.id },
+ onSuccess: onProjectDeletionSuccess,
+ onFailure: ({ value }) => {
+ alert.show(
+ resolveToString(strings.projectDeleteFailureMessage, {
+ message: value.messageForNotification,
+ }),
+ { variant: 'danger' },
+ );
+ },
+ });
+
+ return (
+ <>
+ {/* FIXME: this BlockLoading doesn't look good */}
+ {projectDeletionPending && }
+
+ }
+ persistent
+ >
+ }
+ >
+ {strings.projectViewDetails}
+
+ }
+ >
+ {strings.actionDropdownEditLabel}
+
+ }
+ >
+ {strings.projectDuplicate}
+
+ }
+ href={resolveUrl(adminUrl, `deployments/project/${project.id}/history/`)}
+ external
+ >
+ {strings.projectHistory}
+
+ }
+ >
+ {strings.deleteProject}
+
+
+ >
+ );
+}
+
+export default ProjectActions;
diff --git a/src/components/domain/ProjectActions/styles.module.css b/app/src/components/domain/ProjectActions/styles.module.css
similarity index 100%
rename from src/components/domain/ProjectActions/styles.module.css
rename to app/src/components/domain/ProjectActions/styles.module.css
diff --git a/src/components/domain/RegionKeyFigures/i18n.json b/app/src/components/domain/RegionKeyFigures/i18n.json
similarity index 100%
rename from src/components/domain/RegionKeyFigures/i18n.json
rename to app/src/components/domain/RegionKeyFigures/i18n.json
diff --git a/app/src/components/domain/RegionKeyFigures/index.tsx b/app/src/components/domain/RegionKeyFigures/index.tsx
new file mode 100644
index 000000000..b790588a5
--- /dev/null
+++ b/app/src/components/domain/RegionKeyFigures/index.tsx
@@ -0,0 +1,115 @@
+import {
+ AppealsIcon,
+ AppealsTwoIcon,
+ DrefIcon,
+ FundingCoverageIcon,
+ FundingIcon,
+ TargetedPopulationIcon,
+} from '@ifrc-go/icons';
+import {
+ InfoPopup,
+ KeyFigure,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { getPercentage } from '@ifrc-go/ui/utils';
+import { isNotDefined } from '@togglecorp/fujs';
+
+import type { GoApiResponse } from '#utils/restRequest';
+import { useRequest } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type RegionResponse = GoApiResponse<'/api/v2/region/{id}/'>;
+
+interface Props {
+ regionId: string;
+ regionResponse: RegionResponse | undefined;
+}
+
+function RegionKeyFigures(props: Props) {
+ const {
+ regionId,
+ regionResponse,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const {
+ pending: aggregatedAppealPending,
+ response: aggregatedAppealResponse,
+ } = useRequest({
+ skip: isNotDefined(regionId),
+ url: '/api/v2/appeal/aggregated',
+ query: { region: Number(regionId) },
+ });
+
+ const pending = aggregatedAppealPending;
+
+ if (pending || !aggregatedAppealResponse || !regionResponse) {
+ return null;
+ }
+
+ return (
+ <>
+ }
+ className={styles.keyFigure}
+ value={aggregatedAppealResponse.active_drefs}
+ info={(
+
+ )}
+ label={strings.regionKeyFiguresActiveDrefs}
+ />
+ }
+ className={styles.keyFigure}
+ value={aggregatedAppealResponse.active_appeals}
+ info={(
+
+ )}
+ label={strings.regionKeyFiguresActiveAppeals}
+ />
+ }
+ className={styles.keyFigure}
+ value={aggregatedAppealResponse.amount_requested_dref_included}
+ compactValue
+ label={strings.regionKeyFiguresBudget}
+ />
+ }
+ className={styles.keyFigure}
+ value={getPercentage(
+ aggregatedAppealResponse?.amount_funded,
+ aggregatedAppealResponse?.amount_requested,
+ )}
+ suffix="%"
+ compactValue
+ label={strings.regionKeyFiguresAppealsFunding}
+ />
+ }
+ className={styles.keyFigure}
+ value={aggregatedAppealResponse.target_population}
+ compactValue
+ label={strings.regionKeyFiguresTargetPop}
+ />
+ }
+ className={styles.keyFigure}
+ value={regionResponse.country_plan_count}
+ compactValue
+ label={strings.regionKeyFiguresCountryPlan}
+ />
+ >
+ );
+}
+
+export default RegionKeyFigures;
diff --git a/src/components/domain/RegionKeyFigures/styles.module.css b/app/src/components/domain/RegionKeyFigures/styles.module.css
similarity index 100%
rename from src/components/domain/RegionKeyFigures/styles.module.css
rename to app/src/components/domain/RegionKeyFigures/styles.module.css
diff --git a/src/components/domain/RegionSelectInput.tsx b/app/src/components/domain/RegionSelectInput.tsx
similarity index 88%
rename from src/components/domain/RegionSelectInput.tsx
rename to app/src/components/domain/RegionSelectInput.tsx
index c4048d100..5d845a8ce 100644
--- a/src/components/domain/RegionSelectInput.tsx
+++ b/app/src/components/domain/RegionSelectInput.tsx
@@ -1,8 +1,11 @@
-import type { Props as SelectInputProps } from '#components/SelectInput';
-import SelectInput from '#components/SelectInput';
-import useGlobalEnums from '#hooks/domain/useGlobalEnums';
+import {
+ SelectInput,
+ SelectInputProps,
+} from '@ifrc-go/ui';
+import { stringValueSelector } from '@ifrc-go/ui/utils';
+
import { type components } from '#generated/types';
-import { stringValueSelector } from '#utils/selectors';
+import useGlobalEnums from '#hooks/domain/useGlobalEnums';
export type RegionOption = components<'read'>['schemas']['ApiRegionNameEnum'];
function regionKeySelector(option: RegionOption) {
diff --git a/src/components/domain/RiskImminentEventMap/i18n.json b/app/src/components/domain/RiskImminentEventMap/i18n.json
similarity index 100%
rename from src/components/domain/RiskImminentEventMap/i18n.json
rename to app/src/components/domain/RiskImminentEventMap/i18n.json
diff --git a/app/src/components/domain/RiskImminentEventMap/index.tsx b/app/src/components/domain/RiskImminentEventMap/index.tsx
new file mode 100644
index 000000000..42173d99b
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEventMap/index.tsx
@@ -0,0 +1,367 @@
+import {
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import { ChevronLeftLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ Container,
+ List,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ isDefined,
+ isNotDefined,
+ mapToList,
+} from '@togglecorp/fujs';
+import {
+ MapBounds,
+ MapImage,
+ MapLayer,
+ MapSource,
+} from '@togglecorp/re-map';
+import getBbox from '@turf/bbox';
+import getBuffer from '@turf/buffer';
+import type {
+ LngLatBoundsLike,
+ SymbolLayer,
+} from 'mapbox-gl';
+
+import BaseMap from '#components/domain/BaseMap';
+import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer';
+import { type components } from '#generated/riskTypes';
+import useDebouncedValue from '#hooks/useDebouncedValue';
+import {
+ COLOR_WHITE,
+ DEFAULT_MAP_PADDING,
+ DURATION_MAP_ZOOM,
+} from '#utils/constants';
+
+import {
+ exposureFillLayer,
+ geojsonSourceOptions,
+ hazardKeyToIconmap,
+ hazardPointIconLayout,
+ hazardPointLayer,
+ invisibleLayout,
+ trackArrowLayer,
+ trackOutlineLayer,
+ trackPointLayer,
+} from './mapStyles';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+const mapImageOption = {
+ sdf: true,
+};
+
+type HazardType = components<'read'>['schemas']['HazardTypeEnum'];
+
+const hazardKeys = Object.keys(hazardKeyToIconmap) as HazardType[];
+
+const mapIcons = mapToList(
+ hazardKeyToIconmap,
+ (icon, key) => (icon ? ({ key, icon }) : undefined),
+).filter(isDefined);
+
+type EventPointProperties = {
+ id: string | number,
+ hazard_type: HazardType,
+}
+
+export type EventPointFeature = GeoJSON.Feature;
+
+interface EventItemProps {
+ data: EVENT;
+ onExpandClick: (eventId: number | string) => void;
+}
+
+interface EventDetailProps {
+ data: EVENT;
+ exposure: EXPOSURE | undefined;
+ pending: boolean;
+}
+
+type Footprint = GeoJSON.FeatureCollection | undefined;
+
+interface Props {
+ events: EVENT[] | undefined;
+ keySelector: (event: EVENT) => KEY;
+ pointFeatureSelector: (event: EVENT) => EventPointFeature | undefined;
+ footprintSelector: (activeEventExposure: EXPOSURE | undefined) => Footprint | undefined;
+ activeEventExposure: EXPOSURE | undefined;
+ listItemRenderer: React.ComponentType>;
+ detailRenderer: React.ComponentType>;
+ pending: boolean;
+ sidePanelHeading: React.ReactNode;
+ bbox: LngLatBoundsLike | undefined;
+ onActiveEventChange: (eventId: KEY | undefined) => void;
+ activeEventExposurePending: boolean;
+}
+
+function RiskImminentEventMap<
+ EVENT,
+ EXPOSURE,
+ KEY extends string | number
+>(props: Props) {
+ const {
+ events,
+ pointFeatureSelector,
+ keySelector,
+ listItemRenderer,
+ detailRenderer,
+ pending,
+ activeEventExposure,
+ footprintSelector,
+ sidePanelHeading,
+ bbox,
+ onActiveEventChange,
+ activeEventExposurePending,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const [activeEventId, setActiveEventId] = useState(undefined);
+ const activeEvent = useMemo(
+ () => {
+ if (isNotDefined(activeEventId)) {
+ return undefined;
+ }
+
+ return events?.find(
+ (event) => keySelector(event) === activeEventId,
+ );
+ },
+ [activeEventId, keySelector, events],
+ );
+
+ const activeEventFootprint = useMemo(
+ () => {
+ if (isNotDefined(activeEventId) || activeEventExposurePending) {
+ return undefined;
+ }
+
+ return footprintSelector(activeEventExposure);
+ },
+ [activeEventId, activeEventExposure, activeEventExposurePending, footprintSelector],
+ );
+
+ const bounds = useMemo(
+ () => {
+ if (isNotDefined(activeEvent) || activeEventExposurePending) {
+ return bbox;
+ }
+
+ const activePoint = pointFeatureSelector(activeEvent);
+ if (isNotDefined(activePoint)) {
+ return bbox;
+ }
+ const bufferedPoint = getBuffer(activePoint, 10);
+
+ if (activeEventFootprint) {
+ return getBbox({
+ ...activeEventFootprint,
+ features: [
+ ...activeEventFootprint.features,
+ bufferedPoint,
+ ],
+ });
+ }
+
+ return getBbox(bufferedPoint);
+ },
+ [activeEvent, activeEventFootprint, pointFeatureSelector, bbox, activeEventExposurePending],
+ );
+
+ // Avoid abrupt zooming
+ const boundsSafe = useDebouncedValue(bounds);
+
+ const pointFeatureCollection = useMemo<
+ GeoJSON.FeatureCollection
+ >(
+ () => ({
+ type: 'FeatureCollection' as const,
+ features: events?.map(
+ pointFeatureSelector,
+ ).filter(isDefined) ?? [],
+ }),
+ [events, pointFeatureSelector],
+ );
+
+ const setActiveEventIdSafe = useCallback(
+ (eventId: string | number | undefined) => {
+ const eventIdSafe = eventId as KEY | undefined;
+
+ setActiveEventId(eventIdSafe);
+ onActiveEventChange(eventIdSafe);
+ },
+ [onActiveEventChange],
+ );
+
+ const handlePointClick = useCallback(
+ (e: mapboxgl.MapboxGeoJSONFeature) => {
+ const pointProperties = e.properties as EventPointProperties;
+ setActiveEventIdSafe(pointProperties.id as KEY | undefined);
+ return undefined;
+ },
+ [setActiveEventIdSafe],
+ );
+
+ const eventListRendererParams = useCallback(
+ (_: string | number, event: EVENT): EventItemProps => ({
+ data: event,
+ onExpandClick: setActiveEventIdSafe,
+ }),
+ [setActiveEventIdSafe],
+ );
+
+ const DetailComponent = detailRenderer;
+
+ const [loadedIcons, setLoadedIcons] = useState>({});
+
+ const handleIconLoad = useCallback(
+ (loaded: boolean, key: HazardType) => {
+ setLoadedIcons((prevValue) => ({
+ ...prevValue,
+ [key]: loaded,
+ }));
+ },
+ [],
+ );
+
+ const allIconsLoaded = useMemo(
+ () => (
+ Object.values(loadedIcons)
+ .filter(Boolean).length === mapIcons.length
+ ),
+ [loadedIcons],
+ );
+
+ const hazardPointIconLayer = useMemo>(
+ () => ({
+ type: 'symbol',
+ paint: { 'icon-color': COLOR_WHITE },
+ layout: allIconsLoaded ? hazardPointIconLayout : invisibleLayout,
+ }),
+ [allIconsLoaded],
+ );
+
+ return (
+
+
+
+ {hazardKeys.map((key) => {
+ const url = hazardKeyToIconmap[key];
+
+ if (isNotDefined(url)) {
+ return null;
+ }
+
+ return (
+
+ );
+ })}
+ {/* FIXME: footprint layer should always be the bottom layer */}
+ {activeEventFootprint && (
+
+
+
+
+
+
+ )}
+
+
+
+
+ {boundsSafe && (
+
+ )}
+
+
+ )}
+ >
+ {strings.backToEventsLabel}
+
+ )}
+ >
+ {isNotDefined(activeEventId) && (
+
+ )}
+ {isDefined(activeEvent) && (
+
+ )}
+
+
+ );
+}
+
+export default RiskImminentEventMap;
diff --git a/src/components/domain/RiskImminentEventMap/mapStyles.ts b/app/src/components/domain/RiskImminentEventMap/mapStyles.ts
similarity index 95%
rename from src/components/domain/RiskImminentEventMap/mapStyles.ts
rename to app/src/components/domain/RiskImminentEventMap/mapStyles.ts
index ef1334d51..206d758d7 100644
--- a/src/components/domain/RiskImminentEventMap/mapStyles.ts
+++ b/app/src/components/domain/RiskImminentEventMap/mapStyles.ts
@@ -1,23 +1,28 @@
+import {
+ isDefined,
+ mapToList,
+} from '@togglecorp/fujs';
import type {
- SymbolLayout,
+ CircleLayer,
CirclePaint,
FillLayer,
- LineLayer,
- CircleLayer,
Layout,
+ LineLayer,
SymbolLayer,
+ SymbolLayout,
} from 'mapbox-gl';
-import { hazardTypeToColorMap } from '#utils/domain/risk';
-import { COLOR_BLACK, COLOR_PRIMARY_BLUE } from '#utils/constants';
-import { isDefined, mapToList } from '@togglecorp/fujs';
-import earthquakeIcon from '#assets/icons/risk/earthquake.png';
-import floodIcon from '#assets/icons/risk/flood.png';
import cycloneIcon from '#assets/icons/risk/cyclone.png';
import droughtIcon from '#assets/icons/risk/drought.png';
+import earthquakeIcon from '#assets/icons/risk/earthquake.png';
+import floodIcon from '#assets/icons/risk/flood.png';
import wildfireIcon from '#assets/icons/risk/wildfire.png';
-
import { type components } from '#generated/riskTypes';
+import {
+ COLOR_BLACK,
+ COLOR_PRIMARY_BLUE,
+} from '#utils/constants';
+import { hazardTypeToColorMap } from '#utils/domain/risk';
type HazardType = components<'read'>['schemas']['HazardTypeEnum'];
diff --git a/src/components/domain/RiskImminentEventMap/styles.module.css b/app/src/components/domain/RiskImminentEventMap/styles.module.css
similarity index 100%
rename from src/components/domain/RiskImminentEventMap/styles.module.css
rename to app/src/components/domain/RiskImminentEventMap/styles.module.css
diff --git a/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/i18n.json b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/i18n.json
similarity index 100%
rename from src/components/domain/RiskImminentEvents/Gdacs/EventDetails/i18n.json
rename to app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/i18n.json
diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/index.tsx
new file mode 100644
index 000000000..aa6906e02
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/index.tsx
@@ -0,0 +1,180 @@
+import {
+ BlockLoading,
+ Container,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { isDefined } from '@togglecorp/fujs';
+
+import Link from '#components/Link';
+import { type RiskApiResponse } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type GdacsResponse = RiskApiResponse<'/api/v1/gdacs/'>;
+type GdacsItem = NonNullable[number];
+type GdacsExposure = RiskApiResponse<'/api/v1/gdacs/{id}/exposure/'>;
+
+interface GdacsEventDetails {
+ Class?: string;
+ affectedcountries?: {
+ iso3: string;
+ countryname: string;
+ }[];
+ alertlevel?: string;
+ alertscore?: number;
+ country?: string;
+ countryonland?: string;
+ description?: string;
+ episodealertlevel?: string;
+ episodealertscore?: number;
+ episodeid?: number;
+ eventid?: number;
+ eventname?: string;
+ eventtype?: string;
+ fromdate?: string;
+ glide?: string;
+ htmldescription?: string;
+ icon?: string;
+ iconoverall?: null,
+ iscurrent?: string;
+ iso3?: string;
+ istemporary?: string;
+ name?: string;
+ polygonlabel?: string;
+ todate?: string;
+ severitydata?: {
+ severity?: number;
+ severitytext?: string;
+ severityunit?: string;
+ },
+ source?: string;
+ sourceid?: string;
+ url?: {
+ report?: string;
+ details?: string;
+ geometry?: string;
+ },
+}
+interface GdacsPopulationExposure {
+ death?: number;
+ displaced?: number;
+ exposed_population?: string;
+ people_affected?: string;
+ impact?: string;
+}
+
+interface Props {
+ data: GdacsItem;
+ exposure: GdacsExposure | undefined;
+ pending: boolean;
+}
+
+function EventDetails(props: Props) {
+ const {
+ data: {
+ hazard_name,
+ start_date,
+ event_details,
+ },
+ exposure,
+ pending,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const populationExposure = exposure?.population_exposure as GdacsPopulationExposure | undefined;
+ const eventDetails = event_details as GdacsEventDetails | undefined;
+
+ return (
+
+ )}
+ >
+ {pending && }
+
+ {isDefined(eventDetails?.source) && (
+
+ )}
+ {isDefined(populationExposure?.death) && (
+
+ )}
+ {isDefined(populationExposure?.displaced) && (
+
+ )}
+ {isDefined(populationExposure?.exposed_population) && (
+
+ )}
+ {isDefined(populationExposure?.people_affected) && (
+
+ )}
+ {isDefined(populationExposure?.impact) && (
+
+ )}
+ {isDefined(eventDetails?.severitydata)
+ && (isDefined(eventDetails) && (eventDetails?.eventtype) && !(eventDetails.eventtype === 'FL')) && (
+
+ )}
+ {isDefined(eventDetails?.alertlevel) && (
+
+ )}
+
+ {isDefined(eventDetails)
+ && isDefined(eventDetails.url)
+ && isDefined(eventDetails.url.report)
+ && (
+
+ {strings.eventMoreDetailsLink}
+
+ )}
+
+ );
+}
+
+export default EventDetails;
diff --git a/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css
similarity index 100%
rename from src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css
rename to app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css
diff --git a/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/i18n.json b/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/i18n.json
similarity index 100%
rename from src/components/domain/RiskImminentEvents/Gdacs/EventListItem/i18n.json
rename to app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/i18n.json
diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/index.tsx
new file mode 100644
index 000000000..0a4f257dd
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/index.tsx
@@ -0,0 +1,60 @@
+import { ChevronRightLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ Header,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import { type RiskApiResponse } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type ImminentEventResponse = RiskApiResponse<'/api/v1/gdacs/'>;
+type EventItem = NonNullable[number];
+
+interface Props {
+ data: EventItem;
+ onExpandClick: (eventId: string | number) => void;
+}
+
+function EventListItem(props: Props) {
+ const {
+ data: {
+ id,
+ hazard_name,
+ start_date,
+ },
+ onExpandClick,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ return (
+
+
+
+ )}
+ spacing="condensed"
+ >
+
+
+ );
+}
+
+export default EventListItem;
diff --git a/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/styles.module.css b/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/styles.module.css
similarity index 100%
rename from src/components/domain/RiskImminentEvents/Gdacs/EventListItem/styles.module.css
rename to app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/styles.module.css
diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx b/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx
new file mode 100644
index 000000000..c3eb3ad32
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx
@@ -0,0 +1,185 @@
+import { useCallback } from 'react';
+import { numericIdSelector } from '@ifrc-go/ui/utils';
+import {
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+import { type LngLatBoundsLike } from 'mapbox-gl';
+
+import RiskImminentEventMap, { type EventPointFeature } from '#components/domain/RiskImminentEventMap';
+import { isValidFeatureCollection } from '#utils/domain/risk';
+import {
+ RiskApiResponse,
+ useRiskLazyRequest,
+ useRiskRequest,
+} from '#utils/restRequest';
+
+import EventDetails from './EventDetails';
+import EventListItem from './EventListItem';
+
+type ImminentEventResponse = RiskApiResponse<'/api/v1/gdacs/'>;
+type EventItem = NonNullable[number];
+
+function getLayerType(geometryType: GeoJSON.Geometry['type']) {
+ if (geometryType === 'Point' || geometryType === 'MultiPoint') {
+ return 'track-point';
+ }
+
+ if (geometryType === 'LineString' || geometryType === 'MultiLineString') {
+ return 'track';
+ }
+
+ return 'exposure';
+}
+
+type BaseProps = {
+ title: React.ReactNode;
+ bbox: LngLatBoundsLike | undefined;
+}
+
+type Props = BaseProps & ({
+ variant: 'global';
+} | {
+ variant: 'region';
+ regionId: number;
+} | {
+ variant: 'country';
+ iso3: string;
+})
+
+function Gdacs(props: Props) {
+ const {
+ title,
+ bbox,
+ variant,
+ } = props;
+
+ const {
+ pending: pendingCountryRiskResponse,
+ response: countryRiskResponse,
+ } = useRiskRequest({
+ apiType: 'risk',
+ // eslint-disable-next-line react/destructuring-assignment
+ skip: (variant === 'region' && isNotDefined(props.regionId))
+ // eslint-disable-next-line react/destructuring-assignment
+ || (variant === 'country' && isNotDefined(props.iso3)),
+ url: '/api/v1/gdacs/',
+ query: {
+ limit: 9999,
+ // eslint-disable-next-line react/destructuring-assignment
+ iso3: variant === 'country' ? props.iso3 : undefined,
+ // eslint-disable-next-line react/destructuring-assignment
+ region: variant === 'region' ? [props.regionId] : undefined,
+ },
+ });
+
+ const {
+ response: exposureResponse,
+ pending: exposureResponsePending,
+ trigger: getFootprint,
+ } = useRiskLazyRequest<'/api/v1/gdacs/{id}/exposure/', {
+ eventId: number | string,
+ }>({
+ apiType: 'risk',
+ url: '/api/v1/gdacs/{id}/exposure/',
+ pathVariables: ({ eventId }) => ({ id: Number(eventId) }),
+ });
+
+ const pointFeatureSelector = useCallback(
+ (event: EventItem): EventPointFeature | undefined => {
+ const {
+ id,
+ latitude,
+ longitude,
+ hazard_type,
+ } = event;
+
+ if (
+ isNotDefined(latitude)
+ || isNotDefined(longitude)
+ || isNotDefined(hazard_type)
+ ) {
+ return undefined;
+ }
+
+ return {
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [longitude, latitude],
+ },
+ properties: {
+ id,
+ hazard_type,
+ },
+ };
+ },
+ [],
+ );
+
+ const footprintSelector = useCallback(
+ (exposure: RiskApiResponse<'/api/v1/gdacs/{id}/exposure/'> | undefined) => {
+ if (isNotDefined(exposure)) {
+ return undefined;
+ }
+
+ const { footprint_geojson } = exposure;
+
+ if (isNotDefined(footprint_geojson)) {
+ return undefined;
+ }
+
+ const footprint = isValidFeatureCollection(footprint_geojson)
+ ? footprint_geojson
+ : undefined;
+
+ const geoJson: GeoJSON.FeatureCollection = {
+ type: 'FeatureCollection' as const,
+ features: [
+ ...footprint?.features?.map(
+ (feature) => ({
+ ...feature,
+ properties: {
+ ...feature.properties,
+ type: getLayerType(feature.geometry.type),
+ },
+ }),
+ ) ?? [],
+ ].filter(isDefined),
+ };
+
+ return geoJson;
+ },
+ [],
+ );
+
+ const handleActiveEventChange = useCallback(
+ (eventId: number | undefined) => {
+ if (isDefined(eventId)) {
+ getFootprint({ eventId });
+ } else {
+ getFootprint(undefined);
+ }
+ },
+ [getFootprint],
+ );
+
+ return (
+
+ );
+}
+
+export default Gdacs;
diff --git a/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/i18n.json b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/i18n.json
similarity index 100%
rename from src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/i18n.json
rename to app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/i18n.json
diff --git a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/index.tsx
new file mode 100644
index 000000000..dd1b8bf17
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/index.tsx
@@ -0,0 +1,304 @@
+import {
+ useCallback,
+ useMemo,
+} from 'react';
+import {
+ BlockLoading,
+ Container,
+ DateOutput,
+ NumberOutput,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ resolveToComponent,
+ resolveToString,
+} from '@ifrc-go/ui/utils';
+import {
+ compareString,
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import Link from '#components/Link';
+import { type RiskApiResponse } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type MeteoSwissResponse = RiskApiResponse<'/api/v1/meteoswiss/'>;
+type MeteoSwissItem = NonNullable[number];
+// type MeteoSwissExposure = RiskApiResponse<'/api/v1/meteoswiss/{id}/exposure/'>;
+
+const UPDATED_AT_FORMAT = 'yyyy-MM-dd, hh:mm';
+
+interface Props {
+ data: MeteoSwissItem;
+ // exposure: MeteoSwissExposure | undefined;
+ pending: boolean;
+}
+
+function EventDetails(props: Props) {
+ const {
+ data: {
+ // id,
+ hazard_type_display,
+ country_details,
+ start_date,
+ updated_at,
+ hazard_name,
+ model_name,
+ event_details,
+ },
+ pending,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const hazardName = resolveToString(
+ strings.meteoSwissHazardName,
+ {
+ hazardType: hazard_type_display,
+ hazardName: country_details?.name ?? hazard_name,
+ },
+ );
+
+ const getSaffirSimpsonScaleDescription = useCallback((windspeed: number) => {
+ if (windspeed < 33) {
+ return strings.tropicalStormDescription;
+ }
+ if (windspeed < 43) {
+ return strings.categoryOneDescription;
+ }
+ if (windspeed < 50) {
+ return strings.categoryTwoDescription;
+ }
+ if (windspeed < 59) {
+ return strings.categoryThreeDescription;
+ }
+ if (windspeed < 71) {
+ return strings.categoryFourDescription;
+ }
+ return strings.categoryFiveDescription;
+ }, [
+ strings.tropicalStormDescription,
+ strings.categoryOneDescription,
+ strings.categoryTwoDescription,
+ strings.categoryThreeDescription,
+ strings.categoryFourDescription,
+ strings.categoryFiveDescription,
+ ]);
+
+ const impactList = useMemo(
+ () => (
+ // FIXME: typings should be fixed in the server
+ (event_details as unknown as unknown[])?.map((event: unknown, i: number) => {
+ if (
+ typeof event !== 'object'
+ || isNotDefined(event)
+ || !('mean' in event)
+ || !('impact_type' in event)
+ || !('five_perc' in event)
+ || !('ninety_five_perc' in event)
+ ) {
+ return undefined;
+ }
+
+ const {
+ impact_type,
+ five_perc,
+ ninety_five_perc,
+ mean,
+ } = event;
+
+ const valueSafe = typeof mean === 'number' ? Math.round(mean) : undefined;
+ const fivePercentValue = typeof five_perc === 'number' ? Math.round(five_perc) : undefined;
+ const ninetyFivePercentValue = typeof ninety_five_perc === 'number' ? Math.round(ninety_five_perc) : undefined;
+ if (isNotDefined(valueSafe) || valueSafe === 0) {
+ return undefined;
+ }
+
+ if (typeof impact_type !== 'string') {
+ return undefined;
+ }
+
+ if (impact_type === 'direct_economic_damage') {
+ return {
+ key: i,
+ type: 'economic',
+ value: valueSafe,
+ fivePercentValue,
+ ninetyFivePercentValue,
+ label: strings.meteoSwissEconomicLossLabel,
+ unit: strings.usd,
+ };
+ }
+
+ if (impact_type.startsWith('exposed_population_')) {
+ const windspeed = Number.parseInt(
+ impact_type.split('exposed_population_')[1],
+ 10,
+ );
+
+ if (isNotDefined(windspeed)) {
+ return undefined;
+ }
+
+ return {
+ key: i,
+ type: 'exposure',
+ value: valueSafe,
+ fivePercentValue,
+ ninetyFivePercentValue,
+ label: resolveToString(
+ strings.meteoSwissExposureLabel,
+ {
+ windspeed,
+ saffirSimpsonScale: getSaffirSimpsonScaleDescription(windspeed),
+ },
+ ),
+ unit: strings.people,
+ };
+ }
+
+ return undefined;
+ }).filter(isDefined).sort((a, b) => compareString(b.type, a.type))
+ ),
+ [
+ event_details,
+ strings.meteoSwissEconomicLossLabel,
+ strings.usd,
+ strings.meteoSwissExposureLabel,
+ strings.people,
+ getSaffirSimpsonScaleDescription,
+ ],
+ );
+
+ // TODO: add exposure details
+ return (
+
+
+
+ >
+ )}
+ contentViewType="vertical"
+ >
+ {pending && }
+ {!pending && (
+ <>
+
+ {impactList.map((impact) => (
+ {strings.beta},
+ },
+ )}
+ valueClassName={styles.impactValue}
+ value={resolveToComponent(
+ strings.meteoSwissImpactValue,
+ {
+ value: (
+
+ ),
+ fivePercent: (
+
+ ),
+ ninetyFivePercent: (
+
+ ),
+ unit: impact.unit,
+ },
+ )}
+ strongValue
+ />
+ ))}
+
+ {strings.meteoSwissEstimatesNote}
+
+
+
+ {resolveToComponent(
+ strings.meteoSwissEventDescription,
+ {
+ model: model_name ?? '--',
+ updatedAt: (
+
+ ),
+ eventName: hazard_name,
+ countryName: country_details?.name ?? '--',
+ eventDate: ,
+ },
+ )}
+
+
+ {resolveToComponent(
+ strings.meteoSwissAuthoritativeMessage,
+ {
+ link: (
+
+ {strings.meteoSwissAuthoritativeLinkLabel}
+
+ ),
+ classificationLink: (
+
+ {strings.meteoSwissTropicalStorm}
+
+ ),
+ },
+ )}
+
+ >
+ )}
+
+ );
+}
+
+export default EventDetails;
diff --git a/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/styles.module.css b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/styles.module.css
similarity index 100%
rename from src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/styles.module.css
rename to app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/styles.module.css
diff --git a/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/i18n.json b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/i18n.json
similarity index 100%
rename from src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/i18n.json
rename to app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/i18n.json
diff --git a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/index.tsx
new file mode 100644
index 000000000..f06a13dc1
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/index.tsx
@@ -0,0 +1,64 @@
+import { ChevronRightLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ Header,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import { type RiskApiResponse } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type ImminentEventResponse = RiskApiResponse<'/api/v1/meteoswiss/'>;
+type EventItem = NonNullable[number];
+
+interface Props {
+ data: EventItem;
+ onExpandClick: (eventId: string | number) => void;
+}
+
+function EventListItem(props: Props) {
+ const {
+ data: {
+ id,
+ hazard_type_display,
+ country_details,
+ start_date,
+ hazard_name,
+ },
+ onExpandClick,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const hazardName = `${hazard_type_display} - ${country_details?.name ?? hazard_name}`;
+
+ return (
+
+
+
+ )}
+ spacing="condensed"
+ >
+
+
+ );
+}
+
+export default EventListItem;
diff --git a/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/styles.module.css b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/styles.module.css
similarity index 100%
rename from src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/styles.module.css
rename to app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/styles.module.css
diff --git a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx
new file mode 100644
index 000000000..a10d9abdb
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx
@@ -0,0 +1,198 @@
+import { useCallback } from 'react';
+import { numericIdSelector } from '@ifrc-go/ui/utils';
+import {
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+import type { LngLatBoundsLike } from 'mapbox-gl';
+
+import type { EventPointFeature } from '#components/domain/RiskImminentEventMap';
+import RiskImminentEventMap from '#components/domain/RiskImminentEventMap';
+import { isValidFeatureCollection } from '#utils/domain/risk';
+import {
+ type RiskApiResponse,
+ useRiskLazyRequest,
+ useRiskRequest,
+} from '#utils/restRequest';
+
+import EventDetails from './EventDetails';
+import EventListItem from './EventListItem';
+
+type ImminentEventResponse = RiskApiResponse<'/api/v1/meteoswiss/'>;
+type EventItem = NonNullable[number];
+
+function getLayerType(geometryType: GeoJSON.Geometry['type']) {
+ if (geometryType === 'Point' || geometryType === 'MultiPoint') {
+ return 'track-point';
+ }
+
+ if (geometryType === 'LineString' || geometryType === 'MultiLineString') {
+ return 'track';
+ }
+
+ return 'exposure';
+}
+
+type BaseProps = {
+ title: React.ReactNode;
+ bbox: LngLatBoundsLike | undefined;
+}
+
+type Props = BaseProps & ({
+ variant: 'global';
+} | {
+ variant: 'region';
+ regionId: number;
+} | {
+ variant: 'country';
+ iso3: string;
+})
+
+function MeteoSwiss(props: Props) {
+ const {
+ title,
+ bbox,
+ variant,
+ } = props;
+
+ const {
+ pending: pendingCountryRiskResponse,
+ response: countryRiskResponse,
+ } = useRiskRequest({
+ apiType: 'risk',
+ // eslint-disable-next-line react/destructuring-assignment
+ skip: (variant === 'region' && isNotDefined(props.regionId))
+ // eslint-disable-next-line react/destructuring-assignment
+ || (variant === 'country' && isNotDefined(props.iso3)),
+ url: '/api/v1/meteoswiss/',
+ query: {
+ limit: 9999,
+ // eslint-disable-next-line react/destructuring-assignment
+ iso3: variant === 'country' ? props.iso3 : undefined,
+ // eslint-disable-next-line react/destructuring-assignment
+ region: variant === 'region' ? [props.regionId] : undefined,
+ },
+ });
+
+ const {
+ response: exposureResponse,
+ pending: exposureResponsePending,
+ trigger: getFootprint,
+ } = useRiskLazyRequest<'/api/v1/meteoswiss/{id}/exposure/', {
+ eventId: number | string,
+ }>({
+ apiType: 'risk',
+ url: '/api/v1/meteoswiss/{id}/exposure/',
+ pathVariables: ({ eventId }) => ({ id: Number(eventId) }),
+ });
+
+ const pointFeatureSelector = useCallback(
+ (event: EventItem): EventPointFeature | undefined => {
+ const {
+ id,
+ latitude,
+ longitude,
+ hazard_type,
+ } = event;
+
+ if (
+ isNotDefined(latitude)
+ || isNotDefined(longitude)
+ || isNotDefined(hazard_type)
+ ) {
+ return undefined;
+ }
+
+ return {
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [longitude, latitude],
+ },
+ properties: {
+ id,
+ hazard_type,
+ },
+ };
+ },
+ [],
+ );
+
+ const footprintSelector = useCallback(
+ (exposure: RiskApiResponse<'/api/v1/meteoswiss/{id}/exposure/'> | undefined) => {
+ if (isNotDefined(exposure)) {
+ return undefined;
+ }
+
+ // FIXME: fix typing in server (low priority)
+ const footprint_geojson = exposure?.footprint_geojson?.footprint_geojson;
+
+ if (isNotDefined(footprint_geojson)) {
+ return undefined;
+ }
+
+ const footprint = isValidFeatureCollection(footprint_geojson)
+ ? footprint_geojson
+ : undefined;
+
+ const geoJson: GeoJSON.FeatureCollection = {
+ type: 'FeatureCollection' as const,
+ features: [
+ ...footprint?.features?.map(
+ (feature) => {
+ if (isNotDefined(feature)) {
+ return undefined;
+ }
+
+ const { geometry } = feature;
+ if (isNotDefined(geometry)) {
+ return undefined;
+ }
+
+ return {
+ ...feature,
+ properties: {
+ ...feature.properties,
+ type: getLayerType(feature.geometry.type),
+ },
+ };
+ },
+ ) ?? [],
+ ].filter(isDefined),
+ };
+
+ return geoJson;
+ },
+ [],
+ );
+
+ const handleActiveEventChange = useCallback(
+ (eventId: number | undefined) => {
+ if (isDefined(eventId)) {
+ getFootprint({ eventId });
+ } else {
+ getFootprint(undefined);
+ }
+ },
+ [getFootprint],
+ );
+
+ return (
+
+ );
+}
+
+export default MeteoSwiss;
diff --git a/src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json
similarity index 100%
rename from src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json
rename to app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json
diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/index.tsx
new file mode 100644
index 000000000..475a2770f
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/index.tsx
@@ -0,0 +1,132 @@
+import {
+ BlockLoading,
+ Container,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import { type RiskApiResponse } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type PdcResponse = RiskApiResponse<'/api/v1/pdc/'>;
+type PdcEventItem = NonNullable[number];
+type PdcExposure = RiskApiResponse<'/api/v1/pdc/{id}/exposure/'>;
+
+interface Props {
+ data: PdcEventItem;
+ exposure: PdcExposure | undefined;
+ pending: boolean;
+}
+
+function EventDetails(props: Props) {
+ const {
+ data: {
+ hazard_name,
+ start_date,
+ pdc_created_at,
+ pdc_updated_at,
+ description,
+ },
+ exposure,
+ pending,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ interface Exposure {
+ value?: number | null;
+ valueFormatted?: string | null;
+ }
+
+ // NOTE: these are stored as json so we don't have typings for these
+ const popExposure = exposure?.population_exposure as {
+ total?: Exposure | null;
+ households?: Exposure | null;
+ vulnerable?: Exposure | null;
+ } | null;
+
+ // NOTE: these are stored as json so we don't have typings for these
+ const capitalExposure = exposure?.capital_exposure as {
+ total?: Exposure | null;
+ school?: Exposure | null;
+ hospital?: Exposure | null;
+ } | null;
+
+ return (
+
+
+
+
+ >
+ )}
+ >
+ {pending && }
+ {!pending && (
+ <>
+
+
+
+
+
+
+
+
+
+ {description}
+
+ >
+ )}
+
+ );
+}
+
+export default EventDetails;
diff --git a/src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css
similarity index 100%
rename from src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css
rename to app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css
diff --git a/src/components/domain/RiskImminentEvents/Pdc/EventListItem/i18n.json b/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/i18n.json
similarity index 100%
rename from src/components/domain/RiskImminentEvents/Pdc/EventListItem/i18n.json
rename to app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/i18n.json
diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/index.tsx
new file mode 100644
index 000000000..a2fcbf897
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/index.tsx
@@ -0,0 +1,60 @@
+import { ChevronRightLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ Header,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import { type RiskApiResponse } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type ImminentEventResponse = RiskApiResponse<'/api/v1/pdc/'>;
+type EventItem = NonNullable[number];
+
+interface Props {
+ data: EventItem;
+ onExpandClick: (eventId: string | number) => void;
+}
+
+function EventListItem(props: Props) {
+ const {
+ data: {
+ id,
+ hazard_name,
+ start_date,
+ },
+ onExpandClick,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ return (
+
+
+
+ )}
+ spacing="condensed"
+ >
+
+
+ );
+}
+
+export default EventListItem;
diff --git a/src/components/domain/RiskImminentEvents/Pdc/EventListItem/styles.module.css b/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/styles.module.css
similarity index 100%
rename from src/components/domain/RiskImminentEvents/Pdc/EventListItem/styles.module.css
rename to app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/styles.module.css
diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx b/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx
new file mode 100644
index 000000000..a32409ad0
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx
@@ -0,0 +1,207 @@
+import { useCallback } from 'react';
+import { numericIdSelector } from '@ifrc-go/ui/utils';
+import {
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+import { type LngLatBoundsLike } from 'mapbox-gl';
+
+import RiskImminentEventMap, { type EventPointFeature } from '#components/domain/RiskImminentEventMap';
+import {
+ isValidFeature,
+ isValidPointFeature,
+} from '#utils/domain/risk';
+import {
+ type RiskApiResponse,
+ useRiskLazyRequest,
+ useRiskRequest,
+} from '#utils/restRequest';
+
+import EventDetails from './EventDetails';
+import EventListItem from './EventListItem';
+
+type ImminentEventResponse = RiskApiResponse<'/api/v1/pdc/'>;
+type EventItem = NonNullable[number];
+
+type BaseProps = {
+ title: React.ReactNode;
+ bbox: LngLatBoundsLike | undefined;
+}
+
+type Props = BaseProps & ({
+ variant: 'global';
+} | {
+ variant: 'region';
+ regionId: number;
+} | {
+ variant: 'country';
+ iso3: string;
+})
+
+function Pdc(props: Props) {
+ const {
+ title,
+ bbox,
+ variant,
+ } = props;
+
+ const {
+ pending: pendingCountryRiskResponse,
+ response: countryRiskResponse,
+ } = useRiskRequest({
+ apiType: 'risk',
+ // eslint-disable-next-line react/destructuring-assignment
+ skip: (variant === 'region' && isNotDefined(props.regionId))
+ // eslint-disable-next-line react/destructuring-assignment
+ || (variant === 'country' && isNotDefined(props.iso3)),
+ url: '/api/v1/pdc/',
+ query: {
+ limit: 9999,
+ // eslint-disable-next-line react/destructuring-assignment
+ iso3: variant === 'country' ? props.iso3 : undefined,
+ // eslint-disable-next-line react/destructuring-assignment
+ region: variant === 'region' ? [props.regionId] : undefined,
+ },
+ });
+
+ const {
+ response: exposureResponse,
+ pending: exposureResponsePending,
+ trigger: getFootprint,
+ } = useRiskLazyRequest<'/api/v1/pdc/{id}/exposure/', {
+ eventId: number | string,
+ }>({
+ apiType: 'risk',
+ url: '/api/v1/pdc/{id}/exposure/',
+ pathVariables: ({ eventId }) => ({ id: Number(eventId) }),
+ });
+
+ const pointFeatureSelector = useCallback(
+ (event: EventItem): EventPointFeature | undefined => {
+ const {
+ id,
+ latitude,
+ longitude,
+ hazard_type,
+ } = event;
+
+ if (
+ isNotDefined(latitude)
+ || isNotDefined(longitude)
+ || isNotDefined(hazard_type)
+ ) {
+ return undefined;
+ }
+
+ return {
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [longitude, latitude],
+ },
+ properties: {
+ id,
+ hazard_type,
+ },
+ };
+ },
+ [],
+ );
+
+ const footprintSelector = useCallback(
+ (exposure: RiskApiResponse<'/api/v1/pdc/{id}/exposure/'> | undefined) => {
+ if (isNotDefined(exposure)) {
+ return undefined;
+ }
+
+ const {
+ footprint_geojson,
+ storm_position_geojson,
+ } = exposure;
+
+ if (isNotDefined(footprint_geojson) && isNotDefined(storm_position_geojson)) {
+ return undefined;
+ }
+
+ const footprint = isValidFeature(footprint_geojson) ? footprint_geojson : undefined;
+ // FIXME: fix typing in server (low priority)
+ const stormPositions = (storm_position_geojson as unknown as unknown[] | undefined)
+ ?.filter(isValidPointFeature);
+
+ // forecast_date_time : "2023 SEP 04, 00:00Z"
+ // severity : "WARNING"
+ // storm_name : "HAIKUI"
+ // track_heading : "WNW"
+ // wind_speed_mph : 75
+
+ const geoJson: GeoJSON.FeatureCollection = {
+ type: 'FeatureCollection' as const,
+ features: [
+ footprint ? {
+ ...footprint,
+ properties: {
+ ...footprint.properties,
+ type: 'exposure',
+ },
+ } : undefined,
+ stormPositions ? {
+ type: 'Feature' as const,
+ geometry: {
+ type: 'LineString' as const,
+ coordinates: stormPositions.map(
+ (pointFeature) => (
+ pointFeature.geometry.coordinates
+ ),
+ ),
+ },
+ properties: {
+ type: 'track',
+ },
+ } : undefined,
+ ...stormPositions?.map(
+ (pointFeature) => ({
+ ...pointFeature,
+ properties: {
+ ...pointFeature.properties,
+ type: 'track-point',
+ },
+ }),
+ ) ?? [],
+ ].filter(isDefined),
+ };
+
+ return geoJson;
+ },
+ [],
+ );
+
+ const handleActiveEventChange = useCallback(
+ (eventId: number | undefined) => {
+ if (isDefined(eventId)) {
+ getFootprint({ eventId });
+ } else {
+ getFootprint(undefined);
+ }
+ },
+ [getFootprint],
+ );
+
+ return (
+
+ );
+}
+
+export default Pdc;
diff --git a/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/i18n.json b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/i18n.json
similarity index 100%
rename from src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/i18n.json
rename to app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/i18n.json
diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx
new file mode 100644
index 000000000..40c236513
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx
@@ -0,0 +1,394 @@
+import { useMemo } from 'react';
+import {
+ BlockLoading,
+ Container,
+ TextOutput,
+ Tooltip,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ getPercentage,
+ maxSafe,
+ resolveToString,
+ roundSafe,
+} from '@ifrc-go/ui/utils';
+import {
+ compareDate,
+ isDefined,
+ isFalsyString,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import Link from '#components/Link';
+import {
+ isValidFeatureCollection,
+ isValidPointFeature,
+} from '#utils/domain/risk';
+import { type RiskApiResponse } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type WfpAdamResponse = RiskApiResponse<'/api/v1/adam-exposure/'>;
+type WfpAdamItem = NonNullable[number];
+type WfpAdamExposure = RiskApiResponse<'/api/v1/adam-exposure/{id}/exposure/'>;
+
+interface WfpAdamPopulationExposure {
+ exposure_60_kmh?: number;
+ exposure_90_kmh?: number;
+ exposure_120_kmh?: number;
+}
+
+interface WfpAdamEventDetails {
+ event_id: string;
+ mag?: number;
+ mmni?: number;
+ url?: {
+ map?: string;
+ shakemap?: string;
+ population?: string;
+ population_csv?: string;
+ wind?: string;
+ rainfall?: string;
+ shapefile?: string
+ }
+ iso3?: string;
+ depth?: number;
+ place?: string;
+ title?: string;
+ latitude?: number;
+ longitude?: number;
+ mag_type?: string;
+ admin1_name?: string;
+ published_at?: string;
+ population_impact?: number;
+ country?: number | null;
+ alert_sent?: boolean;
+ alert_level?: 'Red' | 'Orange' | 'Green' | 'Cones' | null;
+ from_date?: string;
+ to_date?: string;
+ wind_speed?: number;
+ effective_date?: string;
+ date_processed?: string;
+ population?: number;
+ dashboard_url?: string;
+ flood_area?: number;
+ fl_croplnd?: number;
+ source?: string;
+ sitrep?: string;
+}
+
+interface Props {
+ data: WfpAdamItem;
+ exposure: WfpAdamExposure | undefined;
+ pending: boolean;
+}
+
+function EventDetails(props: Props) {
+ const {
+ data: {
+ title,
+ publish_date,
+ event_details,
+ },
+ exposure,
+ pending,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const stormPoints = useMemo(
+ () => {
+ if (isNotDefined(exposure)) {
+ return undefined;
+ }
+
+ const { storm_position_geojson } = exposure;
+
+ const geoJson = isValidFeatureCollection(storm_position_geojson)
+ ? storm_position_geojson
+ : undefined;
+
+ const points = geoJson?.features.map(
+ (pointFeature) => {
+ if (
+ !isValidPointFeature(pointFeature)
+ || isNotDefined(pointFeature.properties)
+ ) {
+ return undefined;
+ }
+
+ const {
+ wind_speed,
+ track_date,
+ } = pointFeature.properties;
+
+ if (isNotDefined(wind_speed) || isFalsyString(track_date)) {
+ return undefined;
+ }
+
+ const date = new Date(track_date);
+
+ return {
+ id: date.getTime(),
+ windSpeed: wind_speed,
+ date,
+ };
+ },
+ ).filter(isDefined).sort(
+ (a, b) => compareDate(a.date, b.date),
+ );
+
+ return points;
+ },
+ [exposure],
+ );
+
+ const eventDetails = event_details as WfpAdamEventDetails | undefined;
+
+ // eslint-disable-next-line max-len
+ const populationExposure = exposure?.population_exposure as WfpAdamPopulationExposure | undefined;
+
+ const dashboardUrl = eventDetails?.dashboard_url ?? eventDetails?.url?.map;
+ const populationImpact = roundSafe(eventDetails?.population_impact)
+ ?? roundSafe(eventDetails?.population);
+ const maxWindSpeed = maxSafe(
+ stormPoints?.map(({ windSpeed }) => windSpeed),
+ );
+
+ // TODO: add exposure details
+ return (
+
+ )}
+ >
+ {pending && }
+ {stormPoints && stormPoints.length > 0 && isDefined(maxWindSpeed) && (
+ /* TODO: use proper svg charts */
+
+
+ {stormPoints.map(
+ (point) => (
+
+ ),
+ )}
+
+
+ {strings.wfpChartLabel}
+
+
+ )}
+ {isDefined(eventDetails)
+ && (isDefined(eventDetails.url) || isDefined(eventDetails.dashboard_url)) && (
+
+ {isDefined(dashboardUrl) && (
+
+ {strings.wfpDashboard}
+
+ )}
+ {isDefined(eventDetails?.url) && (
+ <>
+ {isDefined(eventDetails.url.shakemap) && (
+
+ {strings.wfpShakemap}
+
+ )}
+ {isDefined(eventDetails.url.population) && (
+
+ {strings.wfpPopulationTable}
+
+ )}
+ {isDefined(eventDetails.url.wind) && (
+
+ {strings.wfpWind}
+
+ )}
+ {isDefined(eventDetails.url.rainfall) && (
+
+ {strings.wfpRainfall}
+
+ )}
+ {isDefined(eventDetails.url.shapefile) && (
+
+ {strings.wfpShapefile}
+
+ )}
+ >
+ )}
+
+ )}
+
+ {isDefined(eventDetails?.wind_speed) && (
+
+ )}
+ {isDefined(populationImpact) && (
+
+ )}
+
+
+ {isDefined(eventDetails?.source) && (
+
+ )}
+ {isDefined(eventDetails?.sitrep) && (
+
+ )}
+ {isDefined(eventDetails?.mag) && (
+
+ )}
+ {isDefined(eventDetails?.depth) && (
+
+ )}
+ {isDefined(eventDetails?.alert_level) && (
+
+ )}
+ {isDefined(eventDetails?.effective_date) && (
+
+ )}
+ {isDefined(eventDetails?.from_date) && (
+
+ )}
+ {isDefined(eventDetails?.to_date) && (
+
+ )}
+
+
+ {isDefined(populationExposure?.exposure_60_kmh) && (
+
+ )}
+ {isDefined(populationExposure?.exposure_90_kmh) && (
+
+ )}
+ {isDefined(populationExposure?.exposure_120_kmh) && (
+
+ )}
+ {isDefined(eventDetails?.flood_area) && (
+
+ )}
+ {isDefined(eventDetails?.fl_croplnd) && (
+
+ )}
+
+
+ );
+}
+
+export default EventDetails;
diff --git a/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/styles.module.css b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/styles.module.css
similarity index 100%
rename from src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/styles.module.css
rename to app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/styles.module.css
diff --git a/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/i18n.json b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/i18n.json
similarity index 100%
rename from src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/i18n.json
rename to app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/i18n.json
diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/index.tsx
new file mode 100644
index 000000000..1596c34de
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/index.tsx
@@ -0,0 +1,60 @@
+import { ChevronRightLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ Header,
+ TextOutput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import { type RiskApiResponse } from '#utils/restRequest';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type ImminentEventResponse = RiskApiResponse<'/api/v1/adam-exposure/'>;
+type EventItem = NonNullable[number];
+
+interface Props {
+ data: EventItem;
+ onExpandClick: (eventId: string | number) => void;
+}
+
+function EventListItem(props: Props) {
+ const {
+ data: {
+ id,
+ publish_date,
+ title,
+ },
+ onExpandClick,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ return (
+
+
+
+ )}
+ spacing="condensed"
+ >
+
+
+ );
+}
+
+export default EventListItem;
diff --git a/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/styles.module.css b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/styles.module.css
similarity index 100%
rename from src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/styles.module.css
rename to app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/styles.module.css
diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx
new file mode 100644
index 000000000..cc84b27d9
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx
@@ -0,0 +1,187 @@
+import { useCallback } from 'react';
+import { numericIdSelector } from '@ifrc-go/ui/utils';
+import {
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+import { type LngLatBoundsLike } from 'mapbox-gl';
+
+import RiskImminentEventMap, { type EventPointFeature } from '#components/domain/RiskImminentEventMap';
+import { isValidFeatureCollection } from '#utils/domain/risk';
+import {
+ type RiskApiResponse,
+ useRiskLazyRequest,
+ useRiskRequest,
+} from '#utils/restRequest';
+
+import EventDetails from './EventDetails';
+import EventListItem from './EventListItem';
+
+type ImminentEventResponse = RiskApiResponse<'/api/v1/adam-exposure/'>;
+type EventItem = NonNullable[number];
+
+function getLayerType(geometryType: GeoJSON.Geometry['type']) {
+ if (geometryType === 'Point' || geometryType === 'MultiPoint') {
+ return 'track-point';
+ }
+
+ if (geometryType === 'LineString' || geometryType === 'MultiLineString') {
+ return 'track';
+ }
+
+ return 'exposure';
+}
+
+type BaseProps = {
+ title: React.ReactNode;
+ bbox: LngLatBoundsLike | undefined;
+}
+
+type Props = BaseProps & ({
+ variant: 'global';
+} | {
+ variant: 'region';
+ regionId: number;
+} | {
+ variant: 'country';
+ iso3: string;
+})
+
+function WfpAdam(props: Props) {
+ const {
+ title,
+ bbox,
+ variant,
+ } = props;
+
+ const {
+ pending: pendingCountryRiskResponse,
+ response: countryRiskResponse,
+ } = useRiskRequest({
+ apiType: 'risk',
+ // eslint-disable-next-line react/destructuring-assignment
+ skip: (variant === 'region' && isNotDefined(props.regionId))
+ // eslint-disable-next-line react/destructuring-assignment
+ || (variant === 'country' && isNotDefined(props.iso3)),
+ url: '/api/v1/adam-exposure/',
+ query: {
+ limit: 9999,
+ // eslint-disable-next-line react/destructuring-assignment
+ iso3: variant === 'country' ? props.iso3 : undefined,
+ // eslint-disable-next-line react/destructuring-assignment
+ region: variant === 'region' ? [props.regionId] : undefined,
+ },
+ });
+
+ const {
+ response: exposureResponse,
+ pending: exposureResponsePending,
+ trigger: getFootprint,
+ } = useRiskLazyRequest<'/api/v1/adam-exposure/{id}/exposure/', {
+ eventId: number | string,
+ }>({
+ apiType: 'risk',
+ url: '/api/v1/adam-exposure/{id}/exposure/',
+ pathVariables: ({ eventId }) => ({ id: Number(eventId) }),
+ });
+
+ const pointFeatureSelector = useCallback(
+ (event: EventItem): EventPointFeature | undefined => {
+ const {
+ id,
+ event_details,
+ hazard_type,
+ } = event;
+
+ const latitude = event_details?.latitude as number | undefined;
+ const longitude = event_details?.longitude as number | undefined;
+
+ if (
+ isNotDefined(latitude)
+ || isNotDefined(longitude)
+ || isNotDefined(hazard_type)
+ ) {
+ return undefined;
+ }
+
+ return {
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [longitude, latitude],
+ },
+ properties: {
+ id,
+ hazard_type,
+ },
+ };
+ },
+ [],
+ );
+
+ const footprintSelector = useCallback(
+ (exposure: RiskApiResponse<'/api/v1/adam-exposure/{id}/exposure/'> | undefined) => {
+ if (isNotDefined(exposure)) {
+ return undefined;
+ }
+
+ const { storm_position_geojson } = exposure;
+
+ if (isNotDefined(storm_position_geojson)) {
+ return undefined;
+ }
+
+ const stormPositions = isValidFeatureCollection(storm_position_geojson)
+ ? storm_position_geojson
+ : undefined;
+
+ const geoJson: GeoJSON.FeatureCollection = {
+ type: 'FeatureCollection' as const,
+ features: [
+ ...stormPositions?.features?.map(
+ (feature) => ({
+ ...feature,
+ properties: {
+ ...feature.properties,
+ type: getLayerType(feature.geometry.type),
+ },
+ }),
+ ) ?? [],
+ ].filter(isDefined),
+ };
+
+ return geoJson;
+ },
+ [],
+ );
+
+ const handleActiveEventChange = useCallback(
+ (eventId: number | undefined) => {
+ if (isDefined(eventId)) {
+ getFootprint({ eventId });
+ } else {
+ getFootprint(undefined);
+ }
+ },
+ [getFootprint],
+ );
+
+ return (
+
+ );
+}
+
+export default WfpAdam;
diff --git a/src/components/domain/RiskImminentEvents/i18n.json b/app/src/components/domain/RiskImminentEvents/i18n.json
similarity index 100%
rename from src/components/domain/RiskImminentEvents/i18n.json
rename to app/src/components/domain/RiskImminentEvents/i18n.json
diff --git a/app/src/components/domain/RiskImminentEvents/index.tsx b/app/src/components/domain/RiskImminentEvents/index.tsx
new file mode 100644
index 000000000..d40654022
--- /dev/null
+++ b/app/src/components/domain/RiskImminentEvents/index.tsx
@@ -0,0 +1,303 @@
+import {
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ CycloneIcon,
+ DroughtIcon,
+ EarthquakeIcon,
+ FloodIcon,
+ ForestFireIcon,
+} from '@ifrc-go/icons';
+import {
+ Container,
+ InfoPopup,
+ Radio,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { resolveToComponent } from '@ifrc-go/ui/utils';
+import { _cs } from '@togglecorp/fujs';
+import type { LngLatBoundsLike } from 'mapbox-gl';
+
+import Link from '#components/Link';
+import WikiLink from '#components/WikiLink';
+import { environment } from '#config';
+import { type components } from '#generated/riskTypes';
+import { hazardTypeToColorMap } from '#utils/domain/risk';
+
+import Gdacs from './Gdacs';
+import MeteoSwiss from './MeteoSwiss';
+import Pdc from './Pdc';
+import WfpAdam from './WfpAdam';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+export type ImminentEventSource = 'pdc' | 'wfpAdam' | 'gdacs' | 'meteoSwiss';
+type HazardType = components<'read'>['schemas']['HazardTypeEnum'];
+
+type BaseProps = {
+ className?: string;
+ title: React.ReactNode;
+ bbox: LngLatBoundsLike | undefined;
+ defaultSource?: ImminentEventSource;
+}
+
+type Props = BaseProps & ({
+ variant: 'global';
+} | {
+ variant: 'region';
+ regionId: number;
+} | {
+ variant: 'country';
+ iso3: string;
+})
+
+function RiskImminentEvents(props: Props) {
+ const {
+ className,
+ defaultSource = 'pdc',
+ ...otherProps
+ } = props;
+ const [activeView, setActiveView] = useState(defaultSource);
+
+ const strings = useTranslation(i18n);
+
+ const CurrentView = useMemo(
+ () => {
+ if (activeView === 'pdc') {
+ return Pdc;
+ }
+
+ if (activeView === 'wfpAdam') {
+ return WfpAdam;
+ }
+
+ if (activeView === 'gdacs') {
+ return Gdacs;
+ }
+
+ if (activeView === 'meteoSwiss') {
+ return MeteoSwiss;
+ }
+
+ return null;
+ },
+ [activeView],
+ );
+
+ const handleRadioClick = useCallback((key: ImminentEventSource) => {
+ setActiveView(key);
+ }, []);
+
+ const riskHazards: Array<{
+ key: HazardType,
+ label: string,
+ icon: React.ReactNode,
+ }> = useMemo(
+ () => [
+ {
+ key: 'FL',
+ label: strings.imminentEventsFlood,
+ icon: ,
+ },
+ {
+ key: 'TC',
+ label: strings.imminentEventsStorm,
+ icon: ,
+ },
+ {
+ key: 'EQ',
+ label: strings.imminentEventsEarthquake,
+ icon: ,
+ },
+ {
+ key: 'DR',
+ label: strings.imminentEventsDrought,
+ icon: ,
+ },
+ {
+ key: 'WF',
+ label: strings.imminentEventsWildfire,
+ icon: ,
+ },
+ ],
+ [
+ strings.imminentEventsFlood,
+ strings.imminentEventsStorm,
+ strings.imminentEventsEarthquake,
+ strings.imminentEventsDrought,
+ strings.imminentEventsWildfire,
+ ],
+ );
+
+ return (
+
+ )}
+ footerContent={(
+
+ {riskHazards.map((hazard) => (
+
+
+ {hazard.icon}
+
+
+ {hazard.label}
+
+
+ ))}
+
+ )}
+ footerActionsContainerClassName={styles.footerActions}
+ footerActions={(
+ <>
+
+ {strings.here}
+
+ ),
+ },
+ )}
+ />
+ )}
+ />
+
+ {strings.here}
+
+ ),
+ },
+ )}
+ />
+ )}
+ />
+
+ {strings.here}
+
+ ),
+ },
+ )}
+ />
+ )}
+ />
+ {environment !== 'production' && (
+
+
+ {strings.meteoSwissDescriptionOne}
+
+
+ {resolveToComponent(
+ strings.meteoSwissDescriptionTwo,
+ {
+ here: (
+
+ {strings.here}
+
+ ),
+ },
+ )}
+
+
+ )}
+ />
+ )}
+ />
+ )}
+ >
+ )}
+ >
+ {CurrentView && (
+
+ )}
+
+ );
+}
+
+export default RiskImminentEvents;
diff --git a/src/components/domain/RiskImminentEvents/styles.module.css b/app/src/components/domain/RiskImminentEvents/styles.module.css
similarity index 100%
rename from src/components/domain/RiskImminentEvents/styles.module.css
rename to app/src/components/domain/RiskImminentEvents/styles.module.css
diff --git a/src/components/domain/RiskSeasonalMap/Filters/i18n.json b/app/src/components/domain/RiskSeasonalMap/Filters/i18n.json
similarity index 100%
rename from src/components/domain/RiskSeasonalMap/Filters/i18n.json
rename to app/src/components/domain/RiskSeasonalMap/Filters/i18n.json
diff --git a/app/src/components/domain/RiskSeasonalMap/Filters/index.tsx b/app/src/components/domain/RiskSeasonalMap/Filters/index.tsx
new file mode 100644
index 000000000..a898000a4
--- /dev/null
+++ b/app/src/components/domain/RiskSeasonalMap/Filters/index.tsx
@@ -0,0 +1,136 @@
+import { useCallback } from 'react';
+import {
+ Checkbox,
+ MultiSelectInput,
+ SelectInput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ getMonthList,
+ numericKeySelector,
+ stringLabelSelector,
+ stringNameSelector,
+} from '@ifrc-go/ui/utils';
+import { EntriesAsList } from '@togglecorp/toggle-form';
+
+import useCountry from '#hooks/domain/useCountry';
+import {
+ type HazardType,
+ hazardTypeKeySelector,
+ hazardTypeLabelSelector,
+ type HazardTypeOption,
+ type RiskMetric,
+ riskMetricKeySelector,
+ type RiskMetricOption,
+} from '#utils/domain/risk';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+function countryKeySelector(country: { iso3: string }) {
+ return country.iso3;
+}
+
+const monthList = getMonthList();
+
+export interface FilterValue {
+ countries: string[];
+ months: number[];
+ hazardTypes: HazardType[];
+ riskMetric: RiskMetric;
+ normalizeByPopulation: boolean;
+ includeCopingCapacity: boolean;
+}
+
+interface Props {
+ regionId?: number;
+ value: FilterValue;
+ onChange: React.Dispatch>;
+ riskMetricOptions: RiskMetricOption[];
+ hazardTypeOptions: HazardTypeOption[];
+}
+
+function Filters(props: Props) {
+ const {
+ regionId,
+ value,
+ onChange,
+ riskMetricOptions,
+ hazardTypeOptions,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const countryList = useCountry({ region: regionId });
+
+ const handleChange = useCallback(
+ (...args: EntriesAsList) => {
+ const [val, key] = args;
+ onChange((prevValue): FilterValue => ({
+ ...prevValue,
+ [key]: val,
+ }));
+ },
+ [onChange],
+ );
+
+ return (
+
+ );
+}
+
+export default Filters;
diff --git a/src/components/domain/RiskSeasonalMap/Filters/styles.module.css b/app/src/components/domain/RiskSeasonalMap/Filters/styles.module.css
similarity index 100%
rename from src/components/domain/RiskSeasonalMap/Filters/styles.module.css
rename to app/src/components/domain/RiskSeasonalMap/Filters/styles.module.css
diff --git a/src/components/domain/RiskSeasonalMap/i18n.json b/app/src/components/domain/RiskSeasonalMap/i18n.json
similarity index 100%
rename from src/components/domain/RiskSeasonalMap/i18n.json
rename to app/src/components/domain/RiskSeasonalMap/i18n.json
diff --git a/app/src/components/domain/RiskSeasonalMap/index.tsx b/app/src/components/domain/RiskSeasonalMap/index.tsx
new file mode 100644
index 000000000..e367ffa4f
--- /dev/null
+++ b/app/src/components/domain/RiskSeasonalMap/index.tsx
@@ -0,0 +1,1076 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ BlockLoading,
+ Container,
+ LegendItem,
+ Message,
+ TextOutput,
+ Tooltip,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ formatNumber,
+ maxSafe,
+ sumSafe,
+} from '@ifrc-go/ui/utils';
+import {
+ _cs,
+ compareNumber,
+ isDefined,
+ isFalsyString,
+ isNotDefined,
+ listToGroupList,
+ listToMap,
+ mapToList,
+ unique,
+} from '@togglecorp/fujs';
+import {
+ MapBounds,
+ MapLayer,
+} from '@togglecorp/re-map';
+import {
+ type FillLayer,
+ type LngLatBoundsLike,
+} from 'mapbox-gl';
+
+import BaseMap from '#components/domain/BaseMap';
+import Link from '#components/Link';
+import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer';
+import MapPopup from '#components/MapPopup';
+import WikiLink from '#components/WikiLink';
+import useCountry from '#hooks/domain/useCountry';
+import useInputState from '#hooks/useInputState';
+import {
+ CATEGORY_RISK_HIGH,
+ CATEGORY_RISK_LOW,
+ CATEGORY_RISK_MEDIUM,
+ CATEGORY_RISK_VERY_HIGH,
+ CATEGORY_RISK_VERY_LOW,
+ COLOR_DARK_GREY,
+ COLOR_LIGHT_BLUE,
+ COLOR_LIGHT_GREY,
+ COLOR_PRIMARY_RED,
+ DEFAULT_MAP_PADDING,
+ DURATION_MAP_ZOOM,
+} from '#utils/constants';
+import {
+ applicableHazardsByRiskMetric,
+ getDataWithTruthyHazardType,
+ getDisplacementRiskCategory,
+ getExposureRiskCategory,
+ getFiRiskDataItem,
+ getValueForSelectedMonths,
+ hasSomeDefinedValue,
+ type HazardType,
+ type HazardTypeOption,
+ hazardTypeToColorMap,
+ type RiskDataItem,
+ RiskMetric,
+ type RiskMetricOption,
+ riskScoreToCategory,
+} from '#utils/domain/risk';
+import { useRiskRequest } from '#utils/restRequest';
+
+import Filters, { type FilterValue } from './Filters';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+const defaultFilterValue: FilterValue = {
+ months: [],
+ countries: [],
+ riskMetric: 'riskScore',
+ hazardTypes: [],
+ normalizeByPopulation: false,
+ includeCopingCapacity: false,
+};
+
+interface TooltipContentProps {
+ selectedRiskMetric: RiskMetric,
+ valueListByHazard: {
+ value: number;
+ riskCategory: number;
+ hazard_type: HazardType;
+ hazard_type_display: string;
+ }[];
+}
+
+function TooltipContent(props: TooltipContentProps) {
+ const {
+ selectedRiskMetric,
+ valueListByHazard,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const riskCategoryToLabelMap: Record = useMemo(
+ () => ({
+ [CATEGORY_RISK_VERY_LOW]: strings.riskCategoryVeryLow,
+ [CATEGORY_RISK_LOW]: strings.riskCategoryLow,
+ [CATEGORY_RISK_MEDIUM]: strings.riskCategoryMedium,
+ [CATEGORY_RISK_HIGH]: strings.riskCategoryHigh,
+ [CATEGORY_RISK_VERY_HIGH]: strings.riskCategoryVeryHigh,
+ }),
+ [
+ strings.riskCategoryVeryLow,
+ strings.riskCategoryLow,
+ strings.riskCategoryMedium,
+ strings.riskCategoryHigh,
+ strings.riskCategoryVeryHigh,
+ ],
+ );
+
+ const riskMetricLabelMap: Record = {
+ riskScore: strings.riskScoreOptionLabel,
+ displacement: strings.peopleAtRiskOptionLabel,
+ exposure: strings.peopleExposedOptionLabel,
+ };
+
+ return valueListByHazard.map(
+ ({
+ hazard_type_display,
+ hazard_type,
+ riskCategory,
+ value,
+ }) => (
+
+
+
+ )}
+ >
+
+ {selectedRiskMetric !== 'riskScore' && (
+
+ )}
+
+ ),
+ );
+}
+
+const RISK_LOW_COLOR = '#c7d3e0';
+const RISK_HIGH_COLOR = '#f5333f';
+
+interface ClickedPoint {
+ feature: GeoJSON.Feature;
+ lngLat: mapboxgl.LngLatLike;
+}
+
+interface GeoJsonProps {
+ country_id: number;
+ disputed: boolean;
+ fdrs: string;
+ independent: boolean;
+ is_deprecated: boolean;
+ iso: string;
+ iso3: string;
+ name: string;
+ record_type: number;
+ region_id: number;
+}
+
+type BaseProps = {
+ className?: string;
+ bbox: LngLatBoundsLike | undefined;
+}
+
+type Props = BaseProps & ({
+ variant: 'global';
+ regionId?: never;
+} | {
+ variant: 'region';
+ regionId: number;
+});
+
+function RiskSeasonalMap(props: Props) {
+ const {
+ className,
+ bbox,
+ regionId,
+ variant,
+ } = props;
+
+ const [hazardTypeOptions, setHazardTypeOptions] = useInputState([]);
+ const [
+ clickedPointProperties,
+ setClickedPointProperties,
+ ] = useState();
+ const strings = useTranslation(i18n);
+
+ const {
+ response: seasonalResponse,
+ pending: seasonalResponsePending,
+ } = useRiskRequest({
+ skip: variant === 'region' && isNotDefined(regionId),
+ apiType: 'risk',
+ url: '/api/v1/seasonal/',
+ query: variant === 'region'
+ ? { region: regionId }
+ : undefined,
+ });
+
+ const countryList = useCountry(
+ variant === 'region'
+ ? { region: regionId }
+ : {},
+ );
+
+ const countryIso3ToIdMap = useMemo(
+ () => (
+ listToMap(
+ countryList,
+ ({ iso3 }) => iso3.toLowerCase(),
+ ({ id }) => id,
+ )
+ ),
+ [countryList],
+ );
+
+ const {
+ response: riskScoreResponse,
+ pending: riskScoreResponsePending,
+ } = useRiskRequest({
+ skip: variant === 'region' && isNotDefined(regionId),
+ apiType: 'risk',
+ url: '/api/v1/risk-score/',
+ query: variant === 'region'
+ ? {
+ region: regionId,
+ limit: 9999,
+ } : { limit: 9999 },
+ });
+
+ // NOTE: We get single element as array in response
+ const seasonalRiskData = seasonalResponse?.[0];
+ const dataPending = riskScoreResponsePending || seasonalResponsePending;
+
+ const data = useMemo(
+ () => {
+ if (isNotDefined(seasonalRiskData)) {
+ return undefined;
+ }
+
+ const {
+ idmc,
+ ipc_displacement_data,
+ raster_displacement_data,
+ gwis_seasonal,
+ } = seasonalRiskData;
+
+ const displacement = idmc?.map(
+ (dataItem) => {
+ if (!hasSomeDefinedValue(dataItem)) {
+ return undefined;
+ }
+
+ return getDataWithTruthyHazardType(dataItem);
+ },
+ ).filter(isDefined) ?? [];
+
+ const groupedIpc = Object.values(
+ listToGroupList(
+ ipc_displacement_data ?? [],
+ (ipcDataItem) => ipcDataItem.country,
+ ),
+ );
+
+ const exposure = [
+ ...raster_displacement_data?.map(
+ (dataItem) => {
+ if (!hasSomeDefinedValue(dataItem)) {
+ return undefined;
+ }
+
+ return getDataWithTruthyHazardType(dataItem);
+ },
+ ) ?? [],
+ ...groupedIpc.map(getFiRiskDataItem),
+ ].filter(isDefined);
+
+ const riskScore = unique(
+ [
+ ...riskScoreResponse?.results?.map(
+ (dataItem) => {
+ if (!hasSomeDefinedValue(dataItem)) {
+ return undefined;
+ }
+
+ return getDataWithTruthyHazardType(dataItem);
+ },
+ ) ?? [],
+ ...gwis_seasonal?.map(
+ (dataItem) => {
+ if (!hasSomeDefinedValue(dataItem)) {
+ return undefined;
+ }
+
+ return getDataWithTruthyHazardType(dataItem);
+ },
+ ) ?? [],
+ ].filter(isDefined),
+ (item) => `${item.country_details.iso3}-${item.hazard_type}`,
+ );
+
+ return {
+ displacement,
+ exposure,
+ riskScore,
+ };
+ },
+ [seasonalRiskData, riskScoreResponse],
+ );
+
+ const riskMetricOptions: RiskMetricOption[] = useMemo(
+ () => ([
+ {
+ key: 'exposure',
+ label: strings.peopleExposedOptionLabel,
+ applicableHazards: applicableHazardsByRiskMetric.exposure,
+ },
+ {
+ key: 'displacement',
+ label: strings.peopleAtRiskOptionLabel,
+ applicableHazards: applicableHazardsByRiskMetric.displacement,
+ },
+ {
+ key: 'riskScore',
+ label: strings.riskScoreOptionLabel,
+ applicableHazards: applicableHazardsByRiskMetric.riskScore,
+ },
+ ]),
+ [
+ strings.peopleExposedOptionLabel,
+ strings.peopleAtRiskOptionLabel,
+ strings.riskScoreOptionLabel,
+ ],
+ );
+
+ const availableHazards: Record<
+ RiskMetric,
+ { [key in HazardType]?: string }
+ > | undefined = useMemo(
+ () => {
+ if (isNotDefined(data)) {
+ return undefined;
+ }
+
+ return {
+ exposure: {
+ ...listToMap(
+ data.exposure,
+ (item) => item.hazard_type,
+ (item) => item.hazard_type_display,
+ ),
+ },
+ displacement: {
+ ...listToMap(
+ data.displacement,
+ (item) => item.hazard_type,
+ (item) => item.hazard_type_display,
+ ),
+ },
+ riskScore: {
+ ...listToMap(
+ data.riskScore,
+ (item) => item.hazard_type,
+ (item) => item.hazard_type_display,
+ ),
+ },
+ };
+ },
+ [data],
+ );
+
+ const [filters, setFilters] = useInputState(
+ defaultFilterValue,
+ (newValue, oldValue) => {
+ // We only apply side effect when risk metric is changed
+ if (newValue.riskMetric === oldValue.riskMetric) {
+ return newValue;
+ }
+
+ const availableHazardsForSelectedRiskMetric = availableHazards?.[newValue.riskMetric];
+
+ if (isNotDefined(availableHazardsForSelectedRiskMetric)) {
+ return newValue;
+ }
+
+ const selectedRiskMetricDetail = riskMetricOptions.find(
+ (option) => option.key === newValue.riskMetric,
+ );
+
+ if (isNotDefined(selectedRiskMetricDetail)) {
+ return newValue;
+ }
+
+ const newHazardTypeOptions = selectedRiskMetricDetail.applicableHazards.map(
+ (hazardType) => {
+ const hazard_type_display = availableHazardsForSelectedRiskMetric[hazardType];
+ if (isFalsyString(hazard_type_display)) {
+ return undefined;
+ }
+
+ return {
+ hazard_type: hazardType,
+ hazard_type_display,
+ };
+ },
+ ).filter(isDefined);
+
+ setHazardTypeOptions(newHazardTypeOptions);
+
+ return {
+ ...newValue,
+ hazardTypes: newHazardTypeOptions.map(({ hazard_type }) => hazard_type),
+ };
+ },
+ );
+
+ // NOTE: setting default values
+ useEffect(
+ () => {
+ if (
+ isNotDefined(availableHazards)
+ || isNotDefined(availableHazards.riskScore)
+ || isNotDefined(countryList)
+ ) {
+ return;
+ }
+
+ const riskMetric = riskMetricOptions.find(
+ (option) => option.key === 'riskScore',
+ );
+
+ if (isNotDefined(riskMetric)) {
+ return;
+ }
+
+ const newHazardTypeOptions = riskMetric.applicableHazards.map(
+ (hazardType) => {
+ const hazard_type_display = availableHazards.riskScore[hazardType];
+ if (isFalsyString(hazard_type_display)) {
+ return undefined;
+ }
+
+ return {
+ hazard_type: hazardType,
+ hazard_type_display,
+ };
+ },
+ ).filter(isDefined);
+
+ setHazardTypeOptions(newHazardTypeOptions);
+
+ setFilters({
+ countries: countryList.map((country) => country.iso3),
+ riskMetric: riskMetric.key,
+ hazardTypes: riskMetric.applicableHazards.filter(
+ (hazardType) => isDefined(availableHazards[riskMetric.key]?.[hazardType]),
+ ),
+ months: [new Date().getMonth()],
+ normalizeByPopulation: false,
+ includeCopingCapacity: false,
+ });
+ },
+ [countryList, riskMetricOptions, availableHazards, setFilters, setHazardTypeOptions],
+ );
+
+ const mappings = useMemo(
+ () => {
+ if (isNotDefined(riskScoreResponse) || isNotDefined(riskScoreResponse.results)) {
+ return undefined;
+ }
+
+ const riskScoreList = riskScoreResponse.results.map(
+ (item) => {
+ if (
+ isNotDefined(item.country_details)
+ || isFalsyString(item.country_details.iso3)
+ ) {
+ return undefined;
+ }
+
+ return {
+ iso3: item.country_details.iso3,
+ lcc: item.lcc,
+ population_in_thousands: item.population_in_thousands,
+ };
+ },
+ ).filter(isDefined);
+
+ const populationListSafe = riskScoreList.map(
+ (item) => (
+ isDefined(item.population_in_thousands)
+ ? {
+ iso3: item.iso3,
+ population_in_thousands: item.population_in_thousands,
+ } : undefined
+ ),
+ ).filter(isDefined);
+
+ const maxPopulation = maxSafe(
+ populationListSafe.map(
+ (item) => item.population_in_thousands,
+ ),
+ ) ?? 1;
+
+ const populationFactorMap = listToMap(
+ populationListSafe,
+ (result) => result.iso3,
+ (result) => result.population_in_thousands / maxPopulation,
+ );
+
+ const populationMap = listToMap(
+ riskScoreList,
+ (result) => result.iso3,
+ (result) => result.population_in_thousands,
+ );
+
+ const lccListSafe = riskScoreList.map(
+ (item) => (
+ isDefined(item.lcc)
+ ? {
+ iso3: item.iso3,
+ lcc: item.lcc,
+ } : undefined
+ ),
+ ).filter(isDefined);
+
+ const maxLcc = maxSafe(
+ lccListSafe.map(
+ (item) => item.lcc,
+ ),
+ ) ?? 10;
+
+ const lccMap = listToMap(
+ lccListSafe,
+ (item) => item.iso3,
+ (item) => item.lcc,
+ );
+
+ const lccFactorMap = listToMap(
+ lccListSafe,
+ (item) => item.iso3,
+ (item) => item.lcc / maxLcc,
+ );
+
+ return {
+ lcc: lccMap,
+ population: populationMap,
+ populationFactor: populationFactorMap,
+ lccFactor: lccFactorMap,
+ };
+ },
+ [riskScoreResponse],
+ );
+
+ const filteredData = useMemo(
+ () => {
+ const selectedHazards = listToMap(
+ filters.hazardTypes,
+ (hazardType) => hazardType,
+ () => true,
+ );
+
+ const selectedCountries = listToMap(
+ filters.countries,
+ (iso3) => iso3.toLowerCase(),
+ () => true,
+ );
+
+ const selectedMonths = listToMap(
+ filters.months,
+ (monthKey) => monthKey,
+ () => true,
+ );
+
+ type RiskDataItemWithHazard = RiskDataItem & {
+ hazard_type: HazardType;
+ hazard_type_display: string;
+ country_details: {
+ id: number;
+ name?: string | null;
+ iso3?: string | null;
+ }
+ }
+
+ function groupByCountry(riskDataList: RiskDataItemWithHazard[] | undefined) {
+ return listToGroupList(
+ riskDataList?.map(
+ (item) => {
+ const { country_details } = item;
+ if (
+ !selectedHazards[item.hazard_type]
+ || isNotDefined(country_details)
+ || isFalsyString(country_details.iso3)
+ || !selectedCountries[country_details.iso3]
+ || isFalsyString(country_details.name)
+ ) {
+ return undefined;
+ }
+
+ return {
+ ...item,
+ country_details: {
+ id: country_details.id,
+ iso3: country_details.iso3,
+ name: country_details.name,
+ },
+ };
+ },
+ ).filter(isDefined) ?? [],
+ (item) => item.country_details.iso3,
+ );
+ }
+
+ function transformRiskData(
+ riskDataList: RiskDataItemWithHazard[] | undefined,
+ mode: 'sum' | 'max' = 'sum',
+ ) {
+ const transformedList = mapToList(
+ groupByCountry(riskDataList),
+ (itemList, key) => {
+ const firstItem = itemList[0];
+ const valueListByHazard = itemList.map(
+ (item) => {
+ const value = getValueForSelectedMonths(
+ selectedMonths,
+ item,
+ mode,
+ );
+
+ if (isNotDefined(value)) {
+ return undefined;
+ }
+
+ const newValue = filters.riskMetric === 'riskScore'
+ ? riskScoreToCategory(
+ // NOTE: Risk Scores are multiplited
+ // by vulnerability (from server)
+ // So, dividing by 10 to
+ // correct the range (0 - 100 -> 0 - 10)
+ item.hazard_type === 'WF' ? value : (value / 10),
+ item.hazard_type,
+ ) : value;
+
+ if (isNotDefined(newValue)) {
+ return undefined;
+ }
+
+ let riskCategory;
+ if (filters.riskMetric === 'exposure') {
+ riskCategory = getExposureRiskCategory(newValue);
+ } else if (filters.riskMetric === 'displacement') {
+ riskCategory = getDisplacementRiskCategory(newValue);
+ } else if (filters.riskMetric === 'riskScore') {
+ riskCategory = newValue;
+ }
+
+ if (filters.normalizeByPopulation) {
+ const populationFactor = mappings?.populationFactor[
+ item.country_details.iso3
+ ];
+
+ if (isDefined(riskCategory) && isDefined(populationFactor)) {
+ riskCategory = Math.ceil(riskCategory * populationFactor);
+ }
+ }
+
+ if (filters.includeCopingCapacity) {
+ const lccFactor = mappings?.lccFactor[
+ item.country_details.iso3
+ ];
+
+ if (isDefined(riskCategory) && isDefined(lccFactor)) {
+ riskCategory = Math.ceil(riskCategory * lccFactor);
+ }
+ }
+
+ if (isNotDefined(riskCategory)) {
+ return undefined;
+ }
+
+ return {
+ value: newValue,
+ riskCategory,
+ hazard_type: item.hazard_type,
+ hazard_type_display: item.hazard_type_display,
+ };
+ },
+ ).filter(isDefined).sort(
+ (a, b) => compareNumber(a.riskCategory, b.riskCategory, -1),
+ );
+
+ const maxValue = maxSafe(valueListByHazard.map(({ value }) => value));
+ const sum = sumSafe(valueListByHazard.map(({ value }) => value));
+ const maxRiskCategory = maxSafe(
+ valueListByHazard.map(({ riskCategory }) => riskCategory),
+ );
+ const riskCategorySum = sumSafe(
+ valueListByHazard.map(({ riskCategory }) => riskCategory),
+ );
+
+ if (
+ isNotDefined(maxValue)
+ || maxValue === 0
+ || isNotDefined(sum)
+ || sum === 0
+ || isNotDefined(maxRiskCategory)
+ ) {
+ return undefined;
+ }
+
+ const totalValue = sum;
+ const normalizedValueListByHazard = valueListByHazard.map(
+ (item) => ({
+ ...item,
+ normalizedValue: item.value / totalValue,
+ }),
+ );
+
+ return {
+ iso3: key,
+ totalValue,
+ maxValue,
+ riskCategory: maxRiskCategory,
+ riskCategorySum,
+ valueListByHazard: normalizedValueListByHazard,
+ country_details: firstItem.country_details,
+ };
+ },
+ ).filter(isDefined);
+
+ const maxValue = maxSafe(transformedList.map((item) => item.totalValue));
+ if (isNotDefined(maxValue) || maxValue === 0) {
+ return undefined;
+ }
+
+ return transformedList.map(
+ (item) => ({
+ ...item,
+ normalizedValue: item.totalValue / maxValue,
+ maxValue,
+ }),
+ ).sort((a, b) => compareNumber(a.riskCategorySum, b.riskCategorySum, -1));
+ }
+
+ if (filters.riskMetric === 'displacement') {
+ return transformRiskData(
+ data?.displacement,
+ );
+ }
+
+ if (filters.riskMetric === 'exposure') {
+ return transformRiskData(
+ data?.exposure,
+ );
+ }
+
+ if (filters.riskMetric === 'riskScore') {
+ const transformedData = transformRiskData(
+ data?.riskScore,
+ 'max',
+ );
+
+ return transformedData;
+ }
+
+ return undefined;
+ },
+ [data, filters, mappings],
+ );
+
+ const MAX_RISK_SCORE = CATEGORY_RISK_VERY_HIGH;
+
+ // NOTE: we need to generate the layerOptions because we cannot use MapState
+ // The id in the vector tile does not match the id in GO
+ // We also cannot use promoteId as it is a non-managed mapbox source
+ const layerOptions = useMemo>(
+ () => {
+ if (isNotDefined(filteredData) || filteredData.length === 0) {
+ return {
+ type: 'fill',
+ paint: {
+ 'fill-color': COLOR_LIGHT_GREY,
+ },
+ };
+ }
+
+ return {
+ type: 'fill',
+ paint: {
+ 'fill-color': [
+ 'match',
+ ['get', 'iso3'],
+ ...filteredData.flatMap(
+ (item) => [
+ item.country_details.iso3.toUpperCase(),
+ [
+ 'interpolate',
+ ['linear'],
+ ['number', item.riskCategory],
+ CATEGORY_RISK_VERY_LOW,
+ COLOR_LIGHT_BLUE,
+ CATEGORY_RISK_VERY_HIGH,
+ COLOR_PRIMARY_RED,
+ ],
+ ],
+ ),
+ COLOR_LIGHT_GREY,
+ ],
+ 'fill-outline-color': [
+ 'case',
+ ['boolean', ['feature-state', 'hovered'], false],
+ COLOR_DARK_GREY,
+ 'transparent',
+ ],
+ },
+ };
+ },
+ [filteredData],
+ );
+
+ const handleCountryClick = useCallback(
+ (feature: mapboxgl.MapboxGeoJSONFeature, lngLat: mapboxgl.LngLat) => {
+ setClickedPointProperties({
+ feature: feature as unknown as ClickedPoint['feature'],
+ lngLat,
+ });
+ return true;
+ },
+ [setClickedPointProperties],
+ );
+
+ const handlePointClose = useCallback(
+ () => {
+ setClickedPointProperties(undefined);
+ },
+ [setClickedPointProperties],
+ );
+
+ const riskPopupValue = useMemo(() => (
+ filteredData?.find(
+ (filter) => filter.iso3 === clickedPointProperties
+ ?.feature.properties.iso3.toLowerCase(),
+ )
+ ), [filteredData, clickedPointProperties]);
+
+ return (
+
+ )}
+ headerDescriptionContainerClassName={styles.headerDescription}
+ headerDescription={(
+ <>
+
+ {strings.seasonalEventsDescriptionOne}
+
+
+ {strings.seasonalEventsDescriptionTwo}
+
+ >
+ )}
+ actions={(
+
+ )}
+ childrenContainerClassName={styles.mainContent}
+ withHeaderBorder
+ footerClassName={styles.footer}
+ footerContent={(
+
+
{strings.severityLegendLabel}
+
+
+
+
+ {strings.severityLowLabel}
+
+
+ {strings.severityHighLabel}
+
+
+
+
+ )}
+ footerActionsContainerClassName={styles.footerActions}
+ footerActions={(
+
+
+ {strings.hazardsTypeLegendLabel}
+
+
+ {hazardTypeOptions.map((hazard) => (
+
+ ))}
+
+
+ )}
+ >
+
+ )}
+ >
+
+
+ {clickedPointProperties?.lngLat && riskPopupValue && (
+
+ {clickedPointProperties.feature.properties.name}
+
+ )}
+ contentViewType="vertical"
+ childrenContainerClassName={styles.popupContent}
+ >
+
+
+ )}
+
+
+ {dataPending && }
+ {!dataPending && (isNotDefined(filteredData) || filteredData?.length === 0) && (
+
+ )}
+ {/* FIXME: use List */}
+ {!dataPending && filteredData?.map(
+ (dataItem) => {
+ const totalValuePercentage = 100 * dataItem.normalizedValue;
+ if (totalValuePercentage < 1) {
+ return null;
+ }
+
+ const countryId = countryIso3ToIdMap[dataItem.country_details.iso3];
+
+ return (
+
+
+ {dataItem.country_details.name}
+
+
+ {dataItem.valueListByHazard.map(
+ ({
+ hazard_type,
+ riskCategory,
+ }) => {
+ // eslint-disable-next-line max-len
+ const percentage = (100 * riskCategory) / (MAX_RISK_SCORE * filters.hazardTypes.length);
+
+ if (percentage < 1) {
+ return null;
+ }
+
+ return (
+
+ );
+ },
+ )}
+
+
+ )}
+ />
+
+ );
+ },
+ )}
+
+
+ );
+}
+
+export default RiskSeasonalMap;
diff --git a/src/components/domain/RiskSeasonalMap/styles.module.css b/app/src/components/domain/RiskSeasonalMap/styles.module.css
similarity index 100%
rename from src/components/domain/RiskSeasonalMap/styles.module.css
rename to app/src/components/domain/RiskSeasonalMap/styles.module.css
diff --git a/app/src/components/domain/SeverityIndicator/index.tsx b/app/src/components/domain/SeverityIndicator/index.tsx
new file mode 100644
index 000000000..006680b48
--- /dev/null
+++ b/app/src/components/domain/SeverityIndicator/index.tsx
@@ -0,0 +1,40 @@
+import {
+ _cs,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import styles from './styles.module.css';
+
+interface Props {
+ className?: string;
+ level: number | undefined | null;
+ title?: string;
+}
+
+function SeverityIndicator(props: Props) {
+ const {
+ level,
+ title,
+ className,
+ } = props;
+ const classNameMap: Record = {
+ 0: styles.yellow,
+ 1: styles.orange,
+ 2: styles.red,
+ };
+
+ if (isNotDefined(level)) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export default SeverityIndicator;
diff --git a/src/components/domain/SeverityIndicator/styles.module.css b/app/src/components/domain/SeverityIndicator/styles.module.css
similarity index 100%
rename from src/components/domain/SeverityIndicator/styles.module.css
rename to app/src/components/domain/SeverityIndicator/styles.module.css
diff --git a/app/src/components/domain/SurgeCardContainer/index.tsx b/app/src/components/domain/SurgeCardContainer/index.tsx
new file mode 100644
index 000000000..9a91fe520
--- /dev/null
+++ b/app/src/components/domain/SurgeCardContainer/index.tsx
@@ -0,0 +1,28 @@
+import { Container } from '@ifrc-go/ui';
+
+import styles from './styles.module.css';
+
+interface Props {
+ heading: React.ReactNode;
+ children: React.ReactNode;
+}
+
+function SurgeCardContainer(props: Props) {
+ const {
+ heading,
+ children,
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default SurgeCardContainer;
diff --git a/src/components/domain/SurgeCardContainer/styles.module.css b/app/src/components/domain/SurgeCardContainer/styles.module.css
similarity index 100%
rename from src/components/domain/SurgeCardContainer/styles.module.css
rename to app/src/components/domain/SurgeCardContainer/styles.module.css
diff --git a/src/components/domain/SurgeCatalogueContainer/i18n.json b/app/src/components/domain/SurgeCatalogueContainer/i18n.json
similarity index 100%
rename from src/components/domain/SurgeCatalogueContainer/i18n.json
rename to app/src/components/domain/SurgeCatalogueContainer/i18n.json
diff --git a/app/src/components/domain/SurgeCatalogueContainer/index.tsx b/app/src/components/domain/SurgeCatalogueContainer/index.tsx
new file mode 100644
index 000000000..880881826
--- /dev/null
+++ b/app/src/components/domain/SurgeCatalogueContainer/index.tsx
@@ -0,0 +1,86 @@
+import { useCallback } from 'react';
+import { ArrowLeftLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ Container,
+ Image,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { isDefined } from '@togglecorp/fujs';
+
+import useRouting from '#hooks/useRouting';
+
+import { WrappedRoutes } from '../../../App/routes';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+interface ImageListItem {
+ src: string;
+ caption?: string;
+}
+
+interface Props {
+ heading: React.ReactNode;
+ description?: React.ReactNode;
+ children: React.ReactNode;
+ goBackFallbackLink?: keyof WrappedRoutes;
+ imageList?: ImageListItem[];
+}
+
+function SurgeCatalogueContainer(props: Props) {
+ const {
+ heading,
+ description,
+ children,
+ goBackFallbackLink,
+ imageList,
+ } = props;
+
+ const strings = useTranslation(i18n);
+
+ const { goBack } = useRouting();
+ const handleBackButtonClick = useCallback(() => {
+ if (isDefined(goBackFallbackLink)) {
+ goBack(goBackFallbackLink);
+ }
+ }, [goBack, goBackFallbackLink]);
+
+ return (
+ (
+
+ ),
+ )}
+ icons={isDefined(goBackFallbackLink) && (
+
+ )}
+ >
+ {children}
+
+ );
+}
+
+export default SurgeCatalogueContainer;
diff --git a/src/components/domain/SurgeCatalogueContainer/styles.module.css b/app/src/components/domain/SurgeCatalogueContainer/styles.module.css
similarity index 100%
rename from src/components/domain/SurgeCatalogueContainer/styles.module.css
rename to app/src/components/domain/SurgeCatalogueContainer/styles.module.css
diff --git a/app/src/components/domain/SurgeContentContainer/index.tsx b/app/src/components/domain/SurgeContentContainer/index.tsx
new file mode 100644
index 000000000..1017cef0c
--- /dev/null
+++ b/app/src/components/domain/SurgeContentContainer/index.tsx
@@ -0,0 +1,28 @@
+import { Container } from '@ifrc-go/ui';
+
+import styles from './styles.module.css';
+
+interface Props {
+ heading: React.ReactNode;
+ children: React.ReactNode;
+}
+
+function SurgeContentContainer(props: Props) {
+ const {
+ heading,
+ children,
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default SurgeContentContainer;
diff --git a/src/components/domain/SurgeContentContainer/styles.module.css b/app/src/components/domain/SurgeContentContainer/styles.module.css
similarity index 100%
rename from src/components/domain/SurgeContentContainer/styles.module.css
rename to app/src/components/domain/SurgeContentContainer/styles.module.css
diff --git a/src/components/domain/UserSearchMultiSelectInput.tsx b/app/src/components/domain/UserSearchMultiSelectInput.tsx
similarity index 96%
rename from src/components/domain/UserSearchMultiSelectInput.tsx
rename to app/src/components/domain/UserSearchMultiSelectInput.tsx
index a2dd974f7..b7cbcb526 100644
--- a/src/components/domain/UserSearchMultiSelectInput.tsx
+++ b/app/src/components/domain/UserSearchMultiSelectInput.tsx
@@ -1,13 +1,13 @@
import { useState } from 'react';
-
-import SearchMultiSelectInput, {
+import {
+ SearchMultiSelectInput,
SearchMultiSelectInputProps,
-} from '#components/SearchMultiSelectInput';
+} from '@ifrc-go/ui';
-import { useRequest } from '#utils/restRequest';
-import type { GoApiResponse } from '#utils/restRequest';
import useDebouncedValue from '#hooks/useDebouncedValue';
import { getUserName } from '#utils/domain/user';
+import type { GoApiResponse } from '#utils/restRequest';
+import { useRequest } from '#utils/restRequest';
type UserDetails = NonNullable['results']>[number];
export type User = Pick;
diff --git a/src/components/printable/Link/index.tsx b/app/src/components/printable/Link/index.tsx
similarity index 100%
rename from src/components/printable/Link/index.tsx
rename to app/src/components/printable/Link/index.tsx
diff --git a/src/components/printable/Link/styles.module.css b/app/src/components/printable/Link/styles.module.css
similarity index 100%
rename from src/components/printable/Link/styles.module.css
rename to app/src/components/printable/Link/styles.module.css
diff --git a/src/config.ts b/app/src/config.ts
similarity index 100%
rename from src/config.ts
rename to app/src/config.ts
diff --git a/src/contexts/domain.tsx b/app/src/contexts/domain.tsx
similarity index 99%
rename from src/contexts/domain.tsx
rename to app/src/contexts/domain.tsx
index e923d2d1f..b05d9022e 100644
--- a/src/contexts/domain.tsx
+++ b/app/src/contexts/domain.tsx
@@ -1,4 +1,5 @@
import { createContext } from 'react';
+
import { type GoApiResponse } from '#utils/restRequest';
export type CacheKey = 'country' | 'global-enums' | 'disaster-type' | 'user-me' | 'region';
diff --git a/src/contexts/route.tsx b/app/src/contexts/route.tsx
similarity index 99%
rename from src/contexts/route.tsx
rename to app/src/contexts/route.tsx
index 39a9822e7..4f8c9081f 100644
--- a/src/contexts/route.tsx
+++ b/app/src/contexts/route.tsx
@@ -1,4 +1,5 @@
import { createContext } from 'react';
+
import type { WrappedRoutes } from '../App/routes';
const RouteContext = createContext(
diff --git a/src/contexts/user.tsx b/app/src/contexts/user.tsx
similarity index 100%
rename from src/contexts/user.tsx
rename to app/src/contexts/user.tsx
diff --git a/src/declarations/env.d.ts b/app/src/declarations/env.d.ts
similarity index 100%
rename from src/declarations/env.d.ts
rename to app/src/declarations/env.d.ts
diff --git a/src/hooks/domain/useAuth.ts b/app/src/hooks/domain/useAuth.ts
similarity index 86%
rename from src/hooks/domain/useAuth.ts
rename to app/src/hooks/domain/useAuth.ts
index 262ae6fda..f74a7a384 100644
--- a/src/hooks/domain/useAuth.ts
+++ b/app/src/hooks/domain/useAuth.ts
@@ -1,4 +1,7 @@
-import { useContext, useMemo } from 'react';
+import {
+ useContext,
+ useMemo,
+} from 'react';
import { isDefined } from '@togglecorp/fujs';
import UserContext from '#contexts/user';
diff --git a/src/hooks/domain/useCountry.ts b/app/src/hooks/domain/useCountry.ts
similarity index 100%
rename from src/hooks/domain/useCountry.ts
rename to app/src/hooks/domain/useCountry.ts
index 3cc8be194..c1fd2e111 100644
--- a/src/hooks/domain/useCountry.ts
+++ b/app/src/hooks/domain/useCountry.ts
@@ -1,12 +1,12 @@
import {
- isDefined,
- isTruthyString,
-} from '@togglecorp/fujs';
-import {
- useMemo,
useContext,
useEffect,
+ useMemo,
} from 'react';
+import {
+ isDefined,
+ isTruthyString,
+} from '@togglecorp/fujs';
import DomainContext, { type Countries } from '#contexts/domain';
diff --git a/src/hooks/domain/useCountryRaw.ts b/app/src/hooks/domain/useCountryRaw.ts
similarity index 100%
rename from src/hooks/domain/useCountryRaw.ts
rename to app/src/hooks/domain/useCountryRaw.ts
index 7cb74c1c0..057bd420c 100644
--- a/src/hooks/domain/useCountryRaw.ts
+++ b/app/src/hooks/domain/useCountryRaw.ts
@@ -1,9 +1,9 @@
-import { isDefined } from '@togglecorp/fujs';
import {
- useMemo,
useContext,
useEffect,
+ useMemo,
} from 'react';
+import { isDefined } from '@togglecorp/fujs';
import DomainContext, { type Countries } from '#contexts/domain';
diff --git a/src/hooks/domain/useCurrentLanguage.ts b/app/src/hooks/domain/useCurrentLanguage.ts
similarity index 77%
rename from src/hooks/domain/useCurrentLanguage.ts
rename to app/src/hooks/domain/useCurrentLanguage.ts
index 030b0fc94..7aca9307f 100644
--- a/src/hooks/domain/useCurrentLanguage.ts
+++ b/app/src/hooks/domain/useCurrentLanguage.ts
@@ -1,6 +1,5 @@
import { useContext } from 'react';
-
-import LanguageContext from '#contexts/language';
+import { LanguageContext } from '@ifrc-go/ui/contexts';
function useCurrentLanguage() {
const { currentLanguage } = useContext(LanguageContext);
diff --git a/src/hooks/domain/useDisasterType.ts b/app/src/hooks/domain/useDisasterType.ts
similarity index 100%
rename from src/hooks/domain/useDisasterType.ts
rename to app/src/hooks/domain/useDisasterType.ts
index 25847950a..e7575e484 100644
--- a/src/hooks/domain/useDisasterType.ts
+++ b/app/src/hooks/domain/useDisasterType.ts
@@ -1,9 +1,9 @@
-import { isTruthyString } from '@togglecorp/fujs';
import {
useContext,
useEffect,
useMemo,
} from 'react';
+import { isTruthyString } from '@togglecorp/fujs';
import DomainContext, { DisasterTypes } from '#contexts/domain';
diff --git a/src/hooks/domain/useGlobalEnums.ts b/app/src/hooks/domain/useGlobalEnums.ts
similarity index 100%
rename from src/hooks/domain/useGlobalEnums.ts
rename to app/src/hooks/domain/useGlobalEnums.ts
diff --git a/src/hooks/domain/useNationalSociety.ts b/app/src/hooks/domain/useNationalSociety.ts
similarity index 100%
rename from src/hooks/domain/useNationalSociety.ts
rename to app/src/hooks/domain/useNationalSociety.ts
index 58d9ba475..39730d253 100644
--- a/src/hooks/domain/useNationalSociety.ts
+++ b/app/src/hooks/domain/useNationalSociety.ts
@@ -1,19 +1,19 @@
import {
- isDefined,
- isTruthyString,
-} from '@togglecorp/fujs';
-import {
- useMemo,
useContext,
useEffect,
+ useMemo,
} from 'react';
+import {
+ isDefined,
+ isTruthyString,
+} from '@togglecorp/fujs';
import DomainContext from '#contexts/domain';
import {
- type PartialCountry,
type Country,
isValidCountry,
+ type PartialCountry,
} from './useCountry';
export type NationalSociety = Omit & {
diff --git a/src/hooks/domain/usePermissions.ts b/app/src/hooks/domain/usePermissions.ts
similarity index 100%
rename from src/hooks/domain/usePermissions.ts
rename to app/src/hooks/domain/usePermissions.ts
diff --git a/src/hooks/domain/useRegion.ts b/app/src/hooks/domain/useRegion.ts
similarity index 100%
rename from src/hooks/domain/useRegion.ts
rename to app/src/hooks/domain/useRegion.ts
index 9f62b3fec..5c3862a44 100644
--- a/src/hooks/domain/useRegion.ts
+++ b/app/src/hooks/domain/useRegion.ts
@@ -1,11 +1,11 @@
import {
- useMemo,
useContext,
useEffect,
+ useMemo,
} from 'react';
+import { isDefined } from '@togglecorp/fujs';
import DomainContext, { type Regions } from '#contexts/domain';
-import { isDefined } from '@togglecorp/fujs';
export type Region = NonNullable[number];
diff --git a/src/hooks/domain/useUserMe.ts b/app/src/hooks/domain/useUserMe.ts
similarity index 100%
rename from src/hooks/domain/useUserMe.ts
rename to app/src/hooks/domain/useUserMe.ts
diff --git a/src/hooks/useAlert.ts b/app/src/hooks/useAlert.ts
similarity index 82%
rename from src/hooks/useAlert.ts
rename to app/src/hooks/useAlert.ts
index bfa0551e4..328790e7d 100644
--- a/src/hooks/useAlert.ts
+++ b/app/src/hooks/useAlert.ts
@@ -1,9 +1,15 @@
-import { useContext, useCallback, useMemo } from 'react';
+import {
+ useCallback,
+ useContext,
+ useMemo,
+} from 'react';
+import {
+ AlertContext,
+ type AlertType,
+} from '@ifrc-go/ui/contexts';
+import { DURATION_DEFAULT_ALERT_DISMISS } from '@ifrc-go/ui/utils';
import { randomString } from '@togglecorp/fujs';
-import AlertContext, { AlertType } from '#contexts/alert';
-import { DURATION_DEFAULT_ALERT_DISMISS } from '#utils/constants';
-
interface AddAlertOption {
name?: string;
variant?: AlertType;
diff --git a/src/hooks/useChartData.ts b/app/src/hooks/useChartData.ts
similarity index 97%
rename from src/hooks/useChartData.ts
rename to app/src/hooks/useChartData.ts
index b3b96f5fc..641ae6389 100644
--- a/src/hooks/useChartData.ts
+++ b/app/src/hooks/useChartData.ts
@@ -1,17 +1,15 @@
import { useMemo } from 'react';
-import { isDefined } from '@togglecorp/fujs';
-
+import { useSizeTracking } from '@ifrc-go/ui/hooks';
import {
type Bounds,
- type Rect,
+ ChartScale,
getBounds,
getIntervals,
getScaleFunction,
- ChartScale,
-} from '#utils/chart';
-import { formatNumber } from '#utils/common';
-
-import useSizeTracking from '#hooks/useSizeTracking';
+ type Rect,
+} from '@ifrc-go/ui/utils';
+import { formatNumber } from '@ifrc-go/ui/utils';
+import { isDefined } from '@togglecorp/fujs';
type Key = string | number;
diff --git a/src/hooks/useDebouncedValue.ts b/app/src/hooks/useDebouncedValue.ts
similarity index 93%
rename from src/hooks/useDebouncedValue.ts
rename to app/src/hooks/useDebouncedValue.ts
index 35a76f8d2..5cb59a4fd 100644
--- a/src/hooks/useDebouncedValue.ts
+++ b/app/src/hooks/useDebouncedValue.ts
@@ -1,4 +1,7 @@
-import { useState, useEffect } from 'react';
+import {
+ useEffect,
+ useState,
+} from 'react';
function useDebouncedValue(
input: T,
diff --git a/src/hooks/useFilterState.ts b/app/src/hooks/useFilterState.ts
similarity index 98%
rename from src/hooks/useFilterState.ts
rename to app/src/hooks/useFilterState.ts
index c3b171f2c..174e271ec 100644
--- a/src/hooks/useFilterState.ts
+++ b/app/src/hooks/useFilterState.ts
@@ -1,14 +1,14 @@
import {
type SetStateAction,
- useReducer,
useCallback,
useMemo,
+ useReducer,
} from 'react';
+import { hasSomeDefinedValue } from '@ifrc-go/ui/utils';
import { isNotDefined } from '@togglecorp/fujs';
import { EntriesAsList } from '@togglecorp/toggle-form';
import useDebouncedValue from '#hooks/useDebouncedValue';
-import { hasSomeDefinedValue } from '#utils/common';
type SortDirection = 'asc' | 'dsc';
interface SortParameter {
diff --git a/src/hooks/useInputState.ts b/app/src/hooks/useInputState.ts
similarity index 94%
rename from src/hooks/useInputState.ts
rename to app/src/hooks/useInputState.ts
index 9ead3c667..f731be64c 100644
--- a/src/hooks/useInputState.ts
+++ b/app/src/hooks/useInputState.ts
@@ -1,4 +1,7 @@
-import React, { useEffect, useRef } from 'react';
+import React, {
+ useEffect,
+ useRef,
+} from 'react';
type ValueOrSetterFn = T | ((value: T) => T);
function isSetterFn(value: ValueOrSetterFn): value is ((value: T) => T) {
diff --git a/src/hooks/useRecursiveCsvRequest.ts b/app/src/hooks/useRecursiveCsvRequest.ts
similarity index 97%
rename from src/hooks/useRecursiveCsvRequest.ts
rename to app/src/hooks/useRecursiveCsvRequest.ts
index 8ac68f9ee..d1af0c4f7 100644
--- a/src/hooks/useRecursiveCsvRequest.ts
+++ b/app/src/hooks/useRecursiveCsvRequest.ts
@@ -1,4 +1,9 @@
-import { useCallback, useState, useRef } from 'react';
+import {
+ useCallback,
+ useRef,
+ useState,
+} from 'react';
+import { type Language } from '@ifrc-go/ui/contexts';
import {
isDefined,
isFalsyString,
@@ -6,15 +11,17 @@ import {
} from '@togglecorp/fujs';
import Papa from 'papaparse';
-import { KEY_LANGUAGE_STORAGE, KEY_USER_STORAGE } from '#utils/constants';
-import { getFromStorage } from '#utils/localStorage';
-import { resolveUrl } from '#utils/resolveUrl';
-import { type UserAuth } from '#contexts/user';
-import { type Language } from '#contexts/language';
import {
- riskApi,
api,
+ riskApi,
} from '#config';
+import { type UserAuth } from '#contexts/user';
+import {
+ KEY_LANGUAGE_STORAGE,
+ KEY_USER_STORAGE,
+} from '#utils/constants';
+import { getFromStorage } from '#utils/localStorage';
+import { resolveUrl } from '#utils/resolveUrl';
type Maybe = T | null | undefined;
diff --git a/src/hooks/useRouting.ts b/app/src/hooks/useRouting.ts
similarity index 92%
rename from src/hooks/useRouting.ts
rename to app/src/hooks/useRouting.ts
index 5449795fb..477408e17 100644
--- a/src/hooks/useRouting.ts
+++ b/app/src/hooks/useRouting.ts
@@ -1,11 +1,17 @@
-import { useContext, useCallback } from 'react';
import {
- useNavigate,
- useLocation,
+ useCallback,
+ useContext,
+} from 'react';
+import {
type NavigateOptions,
+ useLocation,
+ useNavigate,
} from 'react-router-dom';
-import { resolvePath, type UrlParams } from '#components/Link';
+import {
+ resolvePath,
+ type UrlParams,
+} from '#components/Link';
import RouteContext from '#contexts/route';
import { type WrappedRoutes } from '../App/routes';
diff --git a/src/hooks/useUrlSearchState.ts b/app/src/hooks/useUrlSearchState.ts
similarity index 94%
rename from src/hooks/useUrlSearchState.ts
rename to app/src/hooks/useUrlSearchState.ts
index 4048b4fef..b0e7da62e 100644
--- a/src/hooks/useUrlSearchState.ts
+++ b/app/src/hooks/useUrlSearchState.ts
@@ -4,9 +4,15 @@ import {
useMemo,
useRef,
} from 'react';
-import { encodeDate, isNotDefined } from '@togglecorp/fujs';
+import {
+ NavigateOptions,
+ useSearchParams,
+} from 'react-router-dom';
+import {
+ encodeDate,
+ isNotDefined,
+} from '@togglecorp/fujs';
import { isCallable } from '@togglecorp/toggle-form';
-import { useSearchParams, NavigateOptions } from 'react-router-dom';
type SearchValueFromUrl = string | null | undefined;
type SearchValueFromUser = string | number | boolean | Date | undefined | null;
diff --git a/app/src/index.css b/app/src/index.css
new file mode 100644
index 000000000..1161db1ad
--- /dev/null
+++ b/app/src/index.css
@@ -0,0 +1,40 @@
+* {
+ box-sizing: border-box;
+}
+
+html {
+ @media screen {
+ margin: 0;
+ padding: 0;
+ scrollbar-gutter: stable;
+ }
+}
+
+body {
+ line-height: var(--go-ui-line-height-md);
+ color: var(--go-ui-color-text);
+ font-family: var(--go-ui-font-family-sans-serif);
+ font-size: var(--go-ui-font-size-md);
+ font-weight: var(--go-ui-font-weight-normal);
+
+ @media screen {
+ margin: 0;
+ background-color: var(--go-ui-color-background);
+ padding: 0;
+ }
+}
+
+ul, ol, p {
+ margin: 0;
+}
+
+@media print {
+ @page {
+ size: portrait A4;
+ margin: 10mm 10mm 16mm 10mm;
+ }
+
+ body {
+ font-family: 'Open Sans', sans-serif;
+ }
+}
diff --git a/app/src/index.tsx b/app/src/index.tsx
new file mode 100644
index 000000000..2be01a74e
--- /dev/null
+++ b/app/src/index.tsx
@@ -0,0 +1,81 @@
+import '@ifrc-go/ui/index.css';
+import 'mapbox-gl/dist/mapbox-gl.css';
+import './index.css';
+
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import {
+ createRoutesFromChildren,
+ matchRoutes,
+ useLocation,
+ useNavigationType,
+} from 'react-router-dom';
+import * as Sentry from '@sentry/react';
+import {
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import {
+ api,
+ appCommitHash,
+ appTitle,
+ appVersion,
+ environment,
+ sentryAppDsn,
+ sentryReplaysOnErrorSampleRate,
+ sentryReplaysSessionSampleRate,
+ sentryTracesSampleRate,
+} from '#config';
+
+import App from './App/index.tsx';
+
+if (isDefined(sentryAppDsn)) {
+ Sentry.init({
+ dsn: sentryAppDsn,
+ release: `${appTitle}@${appVersion}+${appCommitHash}`,
+ environment,
+ integrations: [
+ new Sentry.BrowserTracing({
+ routingInstrumentation: Sentry.reactRouterV6Instrumentation(
+ React.useEffect,
+ useLocation,
+ useNavigationType,
+ createRoutesFromChildren,
+ matchRoutes,
+ ),
+ }),
+ new Sentry.Replay(),
+ ],
+
+ // Set tracesSampleRate to 1.0 to capture 100%
+ // of transactions for performance monitoring.
+ tracesSampleRate: Number(sentryTracesSampleRate),
+
+ // Set `tracePropagationTargets` to control for which URLs distributed
+ // tracing should be enabled
+ tracePropagationTargets: [
+ api,
+ // riskApi, TODO: let's add this once sentry is configured for risk server
+ ],
+
+ // Capture Replay for (sentryReplaysSessionSampleRate)% of all sessions,
+ // plus for (sentryReplaysOnErrorSampleRate)% of sessions with an error
+ replaysSessionSampleRate: Number(sentryReplaysSessionSampleRate),
+ replaysOnErrorSampleRate: Number(sentryReplaysOnErrorSampleRate),
+ });
+}
+
+const webappRootId = 'webapp-root';
+const webappRootElement = document.getElementById(webappRootId);
+
+if (isNotDefined(webappRootElement)) {
+ // eslint-disable-next-line no-console
+ console.error(`Could not find html element with id '${webappRootId}'`);
+} else {
+ ReactDOM.createRoot(webappRootElement).render(
+
+
+ ,
+ );
+}
diff --git a/app/src/utils/common.ts b/app/src/utils/common.ts
new file mode 100644
index 000000000..12b1960e1
--- /dev/null
+++ b/app/src/utils/common.ts
@@ -0,0 +1,18 @@
+import type { GoApiResponse } from '#utils/restRequest';
+
+type SearchResponse = GoApiResponse<'/api/v1/search/'>;
+
+type SearchResponseKeys = keyof SearchResponse;
+// eslint-disable-next-line import/prefer-default-export
+export const defaultRanking: Record = {
+ regions: 1,
+ countries: 2,
+ district_province_response: 3,
+
+ emergencies: 4,
+ projects: 5,
+ surge_alerts: 6,
+ surge_deployments: 7,
+ reports: 8,
+ rapid_response_deployments: 9,
+};
diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts
new file mode 100644
index 000000000..16bb07015
--- /dev/null
+++ b/app/src/utils/constants.ts
@@ -0,0 +1,172 @@
+import { type components } from '#generated/types';
+
+export const defaultChartMargin = {
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+};
+
+export const defaultChartPadding = {
+ top: 10,
+ right: 10,
+ bottom: 10,
+ left: 10,
+};
+
+// Map
+export const DURATION_MAP_ZOOM = 1000;
+export const DEFAULT_MAP_PADDING = 50;
+
+// Storage
+
+export const KEY_USER_STORAGE = 'user';
+export const KEY_LANGUAGE_STORAGE = 'language';
+
+// Search page
+
+export const KEY_URL_SEARCH = 'keyword';
+export const SEARCH_TEXT_LENGTH_MIN = 3;
+
+// Risk
+
+export const COLOR_HAZARD_CYCLONE = '#a4bede';
+export const COLOR_HAZARD_DROUGHT = '#b68fba';
+export const COLOR_HAZARD_FOOD_INSECURITY = '#b7c992';
+export const COLOR_HAZARD_FLOOD = '#5a80b0';
+export const COLOR_HAZARD_EARTHQUAKE = '#eca48c';
+export const COLOR_HAZARD_STORM = '#97b8c2';
+export const COLOR_HAZARD_WILDFIRE = '#ff5014';
+
+// FIXME: should these constants satisfy an existing enum?
+export const CATEGORY_RISK_VERY_LOW = 1;
+export const CATEGORY_RISK_LOW = 2;
+export const CATEGORY_RISK_MEDIUM = 3;
+export const CATEGORY_RISK_HIGH = 4;
+export const CATEGORY_RISK_VERY_HIGH = 5;
+
+// FIXME: should these constants satisfy an existing enum?
+export const FORECAST_SEVERITY_UNKNOWN = 0;
+export const FORECAST_SEVERITY_INFO = 1;
+export const FORECAST_SEVERITY_WARNING = 2;
+export const FORECAST_SEVERITY_DANGER = 3;
+
+// Colors
+
+export const COLOR_WHITE = '#ffffff';
+export const COLOR_TEXT = '#313131';
+export const COLOR_TEXT_ON_DARK = COLOR_WHITE;
+export const COLOR_LIGHT_GREY = '#e0e0e0';
+export const COLOR_DARK_GREY = '#a5a5a5';
+export const COLOR_BLACK = '#000000';
+export const COLOR_LIGHT_YELLOW = '#ffd470';
+export const COLOR_YELLOW = '#ff9e00';
+export const COLOR_BLUE = '#4c5d9b';
+export const COLOR_LIGHT_BLUE = '#c7d3e0';
+export const COLOR_ORANGE = '#ff8000';
+export const COLOR_RED = '#f5333f';
+export const COLOR_DARK_RED = '#730413';
+export const COLOR_PRIMARY_BLUE = '#011e41';
+export const COLOR_PRIMARY_RED = '#f5333f';
+
+// Three W
+
+type OperationTypeEnum = components<'read'>['schemas']['OperationTypeEnum'];
+export const OPERATION_TYPE_PROGRAMME = 0 satisfies OperationTypeEnum;
+export const OPERATION_TYPE_EMERGENCY = 1 satisfies OperationTypeEnum;
+export const OPERATION_TYPE_MULTI = -1;
+
+type ProgrammeTypeEnum = components<'read'>['schemas']['Key1d2Enum'];
+export const PROGRAMME_TYPE_MULTILATERAL = 1 satisfies ProgrammeTypeEnum;
+export const PROGRAMME_TYPE_DOMESTIC = 2 satisfies ProgrammeTypeEnum;
+export const PROGRAMME_TYPE_BILATERAL = 0 satisfies ProgrammeTypeEnum;
+
+type StatusTypeEnum = components<'read'>['schemas']['Key1d2Enum'];
+export const PROJECT_STATUS_COMPLETED = 2 satisfies StatusTypeEnum;
+export const PROJECT_STATUS_ONGOING = 1 satisfies StatusTypeEnum;
+export const PROJECT_STATUS_PLANNED = 0 satisfies StatusTypeEnum;
+
+// DREF
+
+// FIXME: fix typing in server (medium priority)
+// This should not be the same as OperationType.
+type DrefStatus = components<'read'>['schemas']['OperationTypeEnum'];
+export const DREF_STATUS_COMPLETED = 1 satisfies DrefStatus;
+export const DREF_STATUS_IN_PROGRESS = 0 satisfies DrefStatus;
+type TypeOfDrefEnum = components<'read'>['schemas']['TypeOfDrefEnum'];
+export const DREF_TYPE_IMMINENT = 0 satisfies TypeOfDrefEnum;
+export const DREF_TYPE_ASSESSMENT = 1 satisfies TypeOfDrefEnum;
+export const DREF_TYPE_RESPONSE = 2 satisfies TypeOfDrefEnum;
+export const DREF_TYPE_LOAN = 3 satisfies TypeOfDrefEnum;
+
+type TypeOfOnsetEnum = components<'read'>['schemas']['TypeValidatedEnum'];
+export const ONSET_SLOW = 1 satisfies TypeOfOnsetEnum;
+
+// Subscriptions
+type SubscriptionRecordTypeEnum = components<'read'>['schemas']['RtypeEnum'];
+export const SUBSCRIPTION_SURGE_ALERT = 3 satisfies SubscriptionRecordTypeEnum;
+export const SUBSCRIPTION_COUNTRY = 4 satisfies SubscriptionRecordTypeEnum;
+export const SUBSCRIPTION_REGION = 5 satisfies SubscriptionRecordTypeEnum;
+export const SUBSCRIPTION_DISASTER_TYPE = 6 satisfies SubscriptionRecordTypeEnum;
+export const SUBSCRIPTION_PER_DUE_DATE = 7 satisfies SubscriptionRecordTypeEnum;
+export const SUBSCRIPTION_FOLLOWED_EVENTS = 8 satisfies SubscriptionRecordTypeEnum;
+export const SUBSCRIPTION_SURGE_DEPLOYMENT_MESSAGES = 9 satisfies SubscriptionRecordTypeEnum;
+export const SUBSCRIPTION_WEEKLY_DIGEST = 11 satisfies SubscriptionRecordTypeEnum;
+export const SUBSCRIPTION_NEW_EMERGENCIES = 12 satisfies SubscriptionRecordTypeEnum;
+export const SUBSCRIPTION_NEW_OPERATIONS = 13 satisfies SubscriptionRecordTypeEnum;
+export const SUBSCRIPTION_GENERAL = 14 satisfies SubscriptionRecordTypeEnum;
+
+// Field Report
+
+export type FieldReportStatusEnum = components<'read'>['schemas']['StatusBb2Enum'];
+export const FIELD_REPORT_STATUS_EARLY_WARNING = 8 satisfies FieldReportStatusEnum;
+export const FIELD_REPORT_STATUS_EVENT = 9 satisfies FieldReportStatusEnum;
+
+export type Bulletin = components<'read'>['schemas']['BulletinEnum'];
+export const BULLETIN_PUBLISHED_NO = 0 satisfies Bulletin;
+export const BULLETIN_PUBLISHED_PLANNED = 2 satisfies Bulletin;
+export const BULLETIN_PUBLISHED_YES = 3 satisfies Bulletin;
+
+type RequestChoices = components<'read'>['schemas']['Key02bEnum'];
+export const REQUEST_CHOICES_NO = 0 satisfies RequestChoices;
+
+export type ContactType = 'Originator' | 'NationalSociety' | 'Federation' | 'Media';
+export type OrganizationType = components<'read'>['schemas']['Key1aeEnum'];
+export type ReportType = components<'read'>['schemas']['FieldReportTypesEnum'];
+export type CategoryType = components<'read'>['schemas']['KeyA87Enum'];
+
+// Common
+
+// FIXME: we need to identify a typesafe way to get this value
+export const DISASTER_TYPE_EPIDEMIC = 1;
+
+export type Visibility = components<'read'>['schemas']['VisibilityD1bEnum'];
+export const VISIBILITY_RCRC_MOVEMENT = 1 satisfies Visibility;
+export const VISIBILITY_IFRC_SECRETARIAT = 2 satisfies Visibility;
+export const VISIBILITY_PUBLIC = 3 satisfies Visibility;
+export const VISIBILITY_IFRC_NS = 4 satisfies Visibility;
+
+export type DisasterCategory = components<'read'>['schemas']['DisasterCategoryEnum'];
+export const DISASTER_CATEGORY_YELLOW = 0 satisfies DisasterCategory;
+export const DISASTER_CATEGORY_ORANGE = 1 satisfies DisasterCategory;
+export const DISASTER_CATEGORY_RED = 2 satisfies DisasterCategory;
+
+export const COUNTRY_AMERICAS_REGION = 282;
+export const COUNTRY_ASIA_REGION = 283;
+export const COUNTRY_AFRICA_REGION = 285;
+export const COUNTRY_EUROPE_REGION = 286;
+export const COUNTRY_MENA_REGION = 287;
+
+export type Region = components<'read'>['schemas']['Key86cEnum'];
+export const REGION_AFRICA = 0 satisfies Region;
+export const REGION_AMERICAS = 1 satisfies Region;
+export const REGION_ASIA = 2 satisfies Region;
+export const REGION_EUROPE = 3 satisfies Region;
+export const REGION_MENA = 4 satisfies Region;
+
+type CountryRecordTypeEnum = components<'read'>['schemas']['RecordTypeEnum'];
+export const COUNTRY_RECORD_TYPE_COUNTRY = 1 satisfies CountryRecordTypeEnum;
+export const COUNTRY_RECORD_TYPE_CLUSTER = 2 satisfies CountryRecordTypeEnum;
+export const COUNTRY_RECORD_TYPE_REGION = 3 satisfies CountryRecordTypeEnum;
+export const COUNTRY_RECORD_TYPE_COUNTRY_OFFICE = 4 satisfies CountryRecordTypeEnum;
+export const COUNTRY_RECORD_TYPE_REPRESENTATIVE_OFFICE = 5 satisfies CountryRecordTypeEnum;
diff --git a/src/components/Table/ColumnShortcuts/CountryLink/index.tsx b/app/src/utils/domain/CountryLink/index.tsx
similarity index 100%
rename from src/components/Table/ColumnShortcuts/CountryLink/index.tsx
rename to app/src/utils/domain/CountryLink/index.tsx
diff --git a/src/components/Table/ColumnShortcuts/RegionLink/index.tsx b/app/src/utils/domain/RegionLink/index.tsx
similarity index 100%
rename from src/components/Table/ColumnShortcuts/RegionLink/index.tsx
rename to app/src/utils/domain/RegionLink/index.tsx
diff --git a/src/utils/domain/country.ts b/app/src/utils/domain/country.ts
similarity index 99%
rename from src/utils/domain/country.ts
rename to app/src/utils/domain/country.ts
index 499dd7c6d..8e8f88373 100644
--- a/src/utils/domain/country.ts
+++ b/app/src/utils/domain/country.ts
@@ -1,17 +1,18 @@
+import { isNotDefined } from '@togglecorp/fujs';
+
import {
COUNTRY_AFRICA_REGION,
COUNTRY_AMERICAS_REGION,
COUNTRY_ASIA_REGION,
COUNTRY_EUROPE_REGION,
COUNTRY_MENA_REGION,
+ type Region,
REGION_AFRICA,
REGION_AMERICAS,
REGION_ASIA,
REGION_EUROPE,
REGION_MENA,
- type Region,
} from '#utils/constants';
-import { isNotDefined } from '@togglecorp/fujs';
// eslint-disable-next-line import/prefer-default-export
export const countryIdToRegionIdMap: Record = {
diff --git a/src/utils/domain/dref.ts b/app/src/utils/domain/dref.ts
similarity index 100%
rename from src/utils/domain/dref.ts
rename to app/src/utils/domain/dref.ts
diff --git a/src/utils/domain/emergency.ts b/app/src/utils/domain/emergency.ts
similarity index 92%
rename from src/utils/domain/emergency.ts
rename to app/src/utils/domain/emergency.ts
index 177a76721..9193d0cf9 100644
--- a/src/utils/domain/emergency.ts
+++ b/app/src/utils/domain/emergency.ts
@@ -1,5 +1,6 @@
+import { sumSafe } from '@ifrc-go/ui/utils';
import { max } from '@togglecorp/fujs';
-import { sumSafe } from '#utils/common';
+
import { type GoApiResponse } from '#utils/restRequest';
type EventResponse = GoApiResponse<'/api/v2/event/'>;
diff --git a/src/utils/domain/per.ts b/app/src/utils/domain/per.ts
similarity index 93%
rename from src/utils/domain/per.ts
rename to app/src/utils/domain/per.ts
index 893ae7ad6..c11af214f 100644
--- a/src/utils/domain/per.ts
+++ b/app/src/utils/domain/per.ts
@@ -1,6 +1,10 @@
-import { isNotDefined, isDefined } from '@togglecorp/fujs';
-import { type GoApiResponse } from '#utils/restRequest';
+import {
+ isDefined,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
import { type components } from '#generated/types';
+import { type GoApiResponse } from '#utils/restRequest';
type PerPhase = components<'read'>['schemas']['PhaseEnum'];
diff --git a/app/src/utils/domain/risk.test.ts b/app/src/utils/domain/risk.test.ts
new file mode 100644
index 000000000..89fa34c9b
--- /dev/null
+++ b/app/src/utils/domain/risk.test.ts
@@ -0,0 +1,26 @@
+import {
+ expect,
+ test,
+} from 'vitest';
+
+import {
+ CATEGORY_RISK_LOW,
+ CATEGORY_RISK_VERY_LOW,
+} from '#utils/constants';
+
+import { riskScoreToCategory } from './risk.ts';
+
+test('Risk score to category', () => {
+ expect(
+ riskScoreToCategory(
+ 0,
+ 'FL',
+ ),
+ ).toEqual(CATEGORY_RISK_VERY_LOW);
+ expect(
+ riskScoreToCategory(
+ 3,
+ 'FL',
+ ),
+ ).toEqual(CATEGORY_RISK_LOW);
+});
diff --git a/src/utils/domain/risk.ts b/app/src/utils/domain/risk.ts
similarity index 99%
rename from src/utils/domain/risk.ts
rename to app/src/utils/domain/risk.ts
index f20534a6a..95dc07cf1 100644
--- a/src/utils/domain/risk.ts
+++ b/app/src/utils/domain/risk.ts
@@ -1,3 +1,8 @@
+import {
+ avgSafe,
+ maxSafe,
+ sumSafe,
+} from '@ifrc-go/ui/utils';
import {
compareNumber,
isDefined,
@@ -9,14 +14,12 @@ import {
unique,
} from '@togglecorp/fujs';
-import { type RiskApiResponse } from '#utils/restRequest';
import { type components } from '#generated/riskTypes';
-import { sumSafe, maxSafe, avgSafe } from '#utils/common';
import {
CATEGORY_RISK_HIGH,
- CATEGORY_RISK_VERY_HIGH,
- CATEGORY_RISK_MEDIUM,
CATEGORY_RISK_LOW,
+ CATEGORY_RISK_MEDIUM,
+ CATEGORY_RISK_VERY_HIGH,
CATEGORY_RISK_VERY_LOW,
COLOR_HAZARD_CYCLONE,
COLOR_HAZARD_DROUGHT,
@@ -27,6 +30,7 @@ import {
COLOR_HAZARD_WILDFIRE,
COLOR_LIGHT_GREY,
} from '#utils/constants';
+import { type RiskApiResponse } from '#utils/restRequest';
export type HazardType = components<'read'>['schemas']['HazardTypeEnum'];
type IpcEstimationType = components<'read'>['schemas']['EstimationTypeEnum'];
diff --git a/app/src/utils/domain/tableHelpers.ts b/app/src/utils/domain/tableHelpers.ts
new file mode 100644
index 000000000..226e95bc9
--- /dev/null
+++ b/app/src/utils/domain/tableHelpers.ts
@@ -0,0 +1,176 @@
+import {
+ type Column,
+ HeaderCell,
+ type HeaderCellProps,
+ ReducedListDisplay,
+ type ReducedListDisplayProps,
+ type SortDirection,
+ TableActionsProps,
+} from '@ifrc-go/ui';
+import { numericIdSelector } from '@ifrc-go/ui/utils';
+
+import Link, { type Props as LinkProps } from '#components/Link';
+import { type GoApiResponse } from '#utils/restRequest';
+
+import type { Props as CountryLinkProps } from './CountryLink';
+import CountryLink from './CountryLink';
+import type { Props as RegionLinkProps } from './RegionLink';
+import RegionLink from './RegionLink';
+
+type Options = {
+ sortable?: boolean,
+ defaultSortDirection?: SortDirection,
+
+ columnClassName?: string;
+ headerCellRendererClassName?: string;
+ headerContainerClassName?: string;
+ cellRendererClassName?: string;
+ cellContainerClassName?: string;
+ columnWidth?: Column['columnWidth'];
+ columnStretch?: Column['columnStretch'];
+ columnStyle?: Column['columnStyle'];
+
+ headerInfoTitle?: HeaderCellProps['infoTitle'];
+ headerInfoDescription?: HeaderCellProps['infoDescription'];
+}
+
+export function createLinkColumn(
+ id: string,
+ title: string,
+ accessor: (item: D) => React.ReactNode,
+ rendererParams: (item: D) => LinkProps,
+ options?: Options,
+) {
+ const item: Column & {
+ valueSelector: (item: D) => string | undefined | null,
+ valueComparator: (foo: D, bar: D) => number,
+ } = {
+ id,
+ title,
+ headerCellRenderer: HeaderCell,
+ headerCellRendererParams: {
+ sortable: options?.sortable,
+ infoTitle: options?.headerInfoTitle,
+ infoDescription: options?.headerInfoDescription,
+ },
+ cellRenderer: Link,
+ cellRendererParams: (_: K, datum: D): LinkProps => ({
+ children: accessor(datum) || '--',
+ withUnderline: true,
+ ...rendererParams(datum),
+ }),
+ valueSelector: () => '',
+ valueComparator: () => 0,
+ cellRendererClassName: options?.cellRendererClassName,
+ columnClassName: options?.columnClassName,
+ headerCellRendererClassName: options?.headerCellRendererClassName,
+ cellContainerClassName: options?.cellContainerClassName,
+ columnWidth: options?.columnWidth,
+ columnStretch: options?.columnStretch,
+ columnStyle: options?.columnStyle,
+ };
+
+ return item;
+}
+
+type CountryResponse = GoApiResponse<'/api/v2/country/'>;
+type CountryListItem = NonNullable[number];
+type PartialCountry = Pick;
+
+type RegionListResponse = GoApiResponse<'/api/v2/region/'>;
+type RegionListItem = NonNullable[number];
+type PartialRegion = Pick;
+
+const countryLinkRendererParams = (country: PartialCountry) => ({
+ id: country.id,
+ name: country.name ?? '?',
+});
+
+const regionLinkRendererParams = (region: PartialRegion) => ({
+ id: region.id,
+ name: region.region_name ?? '',
+});
+
+export function createCountryListColumn(
+ id: string,
+ title: string,
+ countryListSelector: (datum: DATUM) => PartialCountry[] | undefined,
+ options?: Options,
+) {
+ const item: Column<
+ DATUM,
+ KEY,
+ ReducedListDisplayProps,
+ HeaderCellProps
+ > = {
+ id,
+ title,
+ headerCellRenderer: HeaderCell,
+ headerCellRendererParams: {
+ sortable: false,
+ },
+ headerContainerClassName: options?.headerContainerClassName,
+ cellRenderer: ReducedListDisplay,
+ cellRendererParams: (_, datum) => {
+ const countryList = countryListSelector(datum);
+
+ return {
+ list: countryList,
+ renderer: CountryLink,
+ keySelector: numericIdSelector,
+ rendererParams: countryLinkRendererParams,
+ };
+ },
+ cellRendererClassName: options?.cellRendererClassName,
+ columnClassName: options?.columnClassName,
+ headerCellRendererClassName: options?.headerCellRendererClassName,
+ cellContainerClassName: options?.cellContainerClassName,
+ columnWidth: options?.columnWidth,
+ columnStretch: options?.columnStretch,
+ columnStyle: options?.columnStyle,
+ };
+
+ return item;
+}
+
+export function createRegionListColumn(
+ id: string,
+ title: string,
+ regionListSelector: (datum: DATUM) => PartialRegion[] | undefined,
+ options?: Options,
+) {
+ const item: Column<
+ DATUM,
+ KEY,
+ ReducedListDisplayProps,
+ HeaderCellProps
+ > = {
+ id,
+ title,
+ headerCellRenderer: HeaderCell,
+ headerCellRendererParams: {
+ sortable: false,
+ },
+ headerContainerClassName: options?.headerContainerClassName,
+ cellRenderer: ReducedListDisplay,
+ cellRendererParams: (_, datum) => {
+ const regionList = regionListSelector(datum);
+
+ return {
+ list: regionList,
+ renderer: RegionLink,
+ keySelector: numericIdSelector,
+ rendererParams: regionLinkRendererParams,
+ };
+ },
+ cellRendererClassName: options?.cellRendererClassName,
+ columnClassName: options?.columnClassName,
+ headerCellRendererClassName: options?.headerCellRendererClassName,
+ cellContainerClassName: options?.cellContainerClassName,
+ columnWidth: options?.columnWidth,
+ columnStretch: options?.columnStretch,
+ columnStyle: options?.columnStyle,
+ };
+
+ return item;
+}
diff --git a/src/utils/domain/user.ts b/app/src/utils/domain/user.ts
similarity index 90%
rename from src/utils/domain/user.ts
rename to app/src/utils/domain/user.ts
index 86f9e992b..852ce433a 100644
--- a/src/utils/domain/user.ts
+++ b/app/src/utils/domain/user.ts
@@ -1,4 +1,7 @@
-import { isNotDefined, isTruthyString } from '@togglecorp/fujs';
+import {
+ isNotDefined,
+ isTruthyString,
+} from '@togglecorp/fujs';
interface User {
first_name: string | undefined | null;
diff --git a/src/utils/form.ts b/app/src/utils/form.ts
similarity index 100%
rename from src/utils/form.ts
rename to app/src/utils/form.ts
index 98078565b..e08310983 100644
--- a/src/utils/form.ts
+++ b/app/src/utils/form.ts
@@ -1,8 +1,8 @@
+import type { Maybe } from '@togglecorp/fujs';
import {
- isInteger,
isDefined,
+ isInteger,
} from '@togglecorp/fujs';
-import type { Maybe } from '@togglecorp/fujs';
function isNumber(value: unknown): value is number {
return typeof value === 'number';
diff --git a/src/utils/localStorage.ts b/app/src/utils/localStorage.ts
similarity index 100%
rename from src/utils/localStorage.ts
rename to app/src/utils/localStorage.ts
diff --git a/src/utils/map.ts b/app/src/utils/map.ts
similarity index 100%
rename from src/utils/map.ts
rename to app/src/utils/map.ts
index 2805ff60d..b22616ead 100644
--- a/src/utils/map.ts
+++ b/app/src/utils/map.ts
@@ -1,22 +1,22 @@
+import getBbox from '@turf/bbox';
import type {
FillLayer,
- SymbolLayer,
- NavigationControl,
Map,
+ NavigationControl,
+ SymbolLayer,
} from 'mapbox-gl';
-import getBbox from '@turf/bbox';
+import { type Country } from '#hooks/domain/useCountryRaw';
import {
COLOR_BLUE,
- COLOR_RED,
+ COLOR_DARK_GREY,
+ COLOR_LIGHT_GREY,
COLOR_ORANGE,
+ COLOR_RED,
OPERATION_TYPE_EMERGENCY,
OPERATION_TYPE_MULTI,
OPERATION_TYPE_PROGRAMME,
- COLOR_LIGHT_GREY,
- COLOR_DARK_GREY,
} from '#utils/constants';
-import { type Country } from '#hooks/domain/useCountryRaw';
export const defaultMapStyle = 'mapbox://styles/go-ifrc/ckrfe16ru4c8718phmckdfjh0';
type NavControlOptions = NonNullable[0]>;
diff --git a/src/utils/outletContext.ts b/app/src/utils/outletContext.ts
similarity index 100%
rename from src/utils/outletContext.ts
rename to app/src/utils/outletContext.ts
diff --git a/src/utils/resolveUrl.ts b/app/src/utils/resolveUrl.ts
similarity index 100%
rename from src/utils/resolveUrl.ts
rename to app/src/utils/resolveUrl.ts
diff --git a/src/utils/restRequest/error.ts b/app/src/utils/restRequest/error.ts
similarity index 100%
rename from src/utils/restRequest/error.ts
rename to app/src/utils/restRequest/error.ts
diff --git a/src/utils/restRequest/go.ts b/app/src/utils/restRequest/go.ts
similarity index 98%
rename from src/utils/restRequest/go.ts
rename to app/src/utils/restRequest/go.ts
index 192751fc2..a3330dd8c 100644
--- a/src/utils/restRequest/go.ts
+++ b/app/src/utils/restRequest/go.ts
@@ -1,3 +1,4 @@
+import { type Language } from '@ifrc-go/ui/contexts';
import {
isDefined,
isFalsyString,
@@ -5,15 +6,17 @@ import {
} from '@togglecorp/fujs';
import { ContextInterface } from '@togglecorp/toggle-request';
-import { KEY_LANGUAGE_STORAGE, KEY_USER_STORAGE } from '#utils/constants';
-import { getFromStorage } from '#utils/localStorage';
-import { resolveUrl } from '#utils/resolveUrl';
import {
- riskApi,
api,
+ riskApi,
} from '#config';
import { type UserAuth } from '#contexts/user';
-import { type Language } from '#contexts/language';
+import {
+ KEY_LANGUAGE_STORAGE,
+ KEY_USER_STORAGE,
+} from '#utils/constants';
+import { getFromStorage } from '#utils/localStorage';
+import { resolveUrl } from '#utils/resolveUrl';
import { type ResponseObjectError } from './error';
diff --git a/app/src/utils/restRequest/index.ts b/app/src/utils/restRequest/index.ts
new file mode 100644
index 000000000..8dcb1abdf
--- /dev/null
+++ b/app/src/utils/restRequest/index.ts
@@ -0,0 +1,72 @@
+import {
+ RequestContext,
+ useLazyRequest,
+ useRequest,
+} from '@togglecorp/toggle-request';
+
+import type { paths as riskApiPaths } from '#generated/riskTypes';
+import type { paths as goApiPaths } from '#generated/types';
+
+import type {
+ ApiBody,
+ ApiResponse,
+ ApiUrlQuery,
+ CustomLazyRequestOptions,
+ CustomLazyRequestReturn,
+ CustomRequestOptions,
+ CustomRequestReturn,
+ VALID_METHOD,
+} from './overrideTypes';
+
+export type GoApiResponse = ApiResponse;
+export type GoApiUrlQuery = ApiUrlQuery
+export type GoApiBody = ApiBody
+export type RiskApiResponse = ApiResponse;
+export type RiskApiUrlQuery = ApiUrlQuery
+export type RiskApiBody = ApiBody
+
+export type ListResponseItem
+} | undefined> = NonNullable['results']>[number];
+
+// FIXME: identify a way to do this without a cast
+const useGoRequest = useRequest as <
+ PATH extends keyof goApiPaths,
+ METHOD extends VALID_METHOD | undefined = 'GET',
+>(
+ requestOptions: CustomRequestOptions
+) => CustomRequestReturn;
+
+// FIXME: identify a way to do this without a cast
+const useGoLazyRequest = useLazyRequest as <
+ PATH extends keyof goApiPaths,
+ CONTEXT = unknown,
+ METHOD extends VALID_METHOD | undefined = 'GET',
+>(
+ requestOptions: CustomLazyRequestOptions
+) => CustomLazyRequestReturn;
+
+// FIXME: identify a way to do this without a cast
+const useRiskRequest = useRequest as <
+ PATH extends keyof riskApiPaths,
+ METHOD extends VALID_METHOD | undefined = 'GET',
+>(
+ requestOptions: CustomRequestOptions & { apiType: 'risk' },
+) => CustomRequestReturn;
+
+// FIXME: identify a way to do this without a cast
+const useRiskLazyRequest = useLazyRequest as <
+ PATH extends keyof riskApiPaths,
+ CONTEXT = unknown,
+ METHOD extends VALID_METHOD | undefined = 'GET',
+>(
+ requestOptions: CustomLazyRequestOptions & { apiType: 'risk' }
+) => CustomLazyRequestReturn;
+
+export {
+ RequestContext,
+ useGoLazyRequest as useLazyRequest,
+ useGoRequest as useRequest,
+ useRiskLazyRequest,
+ useRiskRequest,
+};
diff --git a/src/utils/restRequest/overrideTypes.ts b/app/src/utils/restRequest/overrideTypes.ts
similarity index 99%
rename from src/utils/restRequest/overrideTypes.ts
rename to app/src/utils/restRequest/overrideTypes.ts
index 58ffe5ff6..85343d986 100644
--- a/src/utils/restRequest/overrideTypes.ts
+++ b/app/src/utils/restRequest/overrideTypes.ts
@@ -1,14 +1,14 @@
+import { type DeepNevaRemove } from '@ifrc-go/ui/utils';
import {
- RequestOptions,
LazyRequestOptions,
- useRequest,
+ RequestOptions,
useLazyRequest,
+ useRequest,
} from '@togglecorp/toggle-request';
-import { type DeepNevaRemove } from '#utils/common';
import {
- TransformedError,
AdditionalOptions,
+ TransformedError,
} from './go';
export type VALID_METHOD = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
diff --git a/src/utils/routes.test.tsx b/app/src/utils/routes.test.tsx
similarity index 89%
rename from src/utils/routes.test.tsx
rename to app/src/utils/routes.test.tsx
index 28139247f..463352a85 100644
--- a/src/utils/routes.test.tsx
+++ b/app/src/utils/routes.test.tsx
@@ -1,5 +1,12 @@
-import { expect, test } from 'vitest';
-import { trimChar, joinUrlPart } from './routes.tsx';
+import {
+ expect,
+ test,
+} from 'vitest';
+
+import {
+ joinUrlPart,
+ trimChar,
+} from './routes.tsx';
// Edit an assertion and save to see HMR in action
diff --git a/src/utils/routes.tsx b/app/src/utils/routes.tsx
similarity index 100%
rename from src/utils/routes.tsx
rename to app/src/utils/routes.tsx
index 5dccdeaca..a941984c2 100644
--- a/src/utils/routes.tsx
+++ b/app/src/utils/routes.tsx
@@ -1,15 +1,15 @@
-import {
- listToMap,
- mapToList,
- randomString,
- isNotDefined,
- isDefined,
-} from '@togglecorp/fujs';
import {
IndexRouteObject,
NonIndexRouteObject,
RouteObject,
} from 'react-router-dom';
+import {
+ isDefined,
+ isNotDefined,
+ listToMap,
+ mapToList,
+ randomString,
+} from '@togglecorp/fujs';
export function trimChar(str: string, char: string) {
let op = str;
diff --git a/src/views/Account/i18n.json b/app/src/views/Account/i18n.json
similarity index 100%
rename from src/views/Account/i18n.json
rename to app/src/views/Account/i18n.json
diff --git a/app/src/views/Account/index.tsx b/app/src/views/Account/index.tsx
new file mode 100644
index 000000000..4b9ef0da2
--- /dev/null
+++ b/app/src/views/Account/index.tsx
@@ -0,0 +1,58 @@
+import { useContext } from 'react';
+import { Outlet } from 'react-router-dom';
+import { NavigationTabList } from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+import NavigationTab from '#components/NavigationTab';
+import Page from '#components/Page';
+import WikiLink from '#components/WikiLink';
+import UserContext from '#contexts/user';
+import useUserMe from '#hooks/domain/useUserMe';
+import { getUserName } from '#utils/domain/user';
+
+import i18n from './i18n.json';
+
+// eslint-disable-next-line import/prefer-default-export
+export function Component() {
+ const strings = useTranslation(i18n);
+ const { userAuth: userDetails } = useContext(UserContext);
+ const userMe = useUserMe();
+
+ return (
+
+ )}
+ heading={
+ userMe
+ ? getUserName(userMe)
+ : userDetails?.displayName ?? '--'
+ }
+ >
+
+
+ {strings.accountDetailsTabTitle}
+
+
+ {strings.accountMyFormTabTitle}
+
+
+ {strings.accountNotificationTabTitle}
+
+
+
+
+ );
+}
+
+Component.displayName = 'Account';
diff --git a/src/views/AccountDetails/ChangePassword/i18n.json b/app/src/views/AccountDetails/ChangePassword/i18n.json
similarity index 100%
rename from src/views/AccountDetails/ChangePassword/i18n.json
rename to app/src/views/AccountDetails/ChangePassword/i18n.json
diff --git a/app/src/views/AccountDetails/ChangePassword/index.tsx b/app/src/views/AccountDetails/ChangePassword/index.tsx
new file mode 100644
index 000000000..8561b5fb8
--- /dev/null
+++ b/app/src/views/AccountDetails/ChangePassword/index.tsx
@@ -0,0 +1,224 @@
+import {
+ useCallback,
+ useMemo,
+} from 'react';
+import {
+ Button,
+ Modal,
+ TextInput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { isTruthyString } from '@togglecorp/fujs';
+import {
+ addCondition,
+ createSubmitHandler,
+ getErrorObject,
+ ObjectSchema,
+ PartialForm,
+ requiredStringCondition,
+ undefinedValue,
+ useForm,
+} from '@togglecorp/toggle-form';
+
+import NonFieldError from '#components/NonFieldError';
+import useAlert from '#hooks/useAlert';
+import {
+ GoApiBody,
+ useLazyRequest,
+} from '#utils/restRequest';
+import { transformObjectError } from '#utils/restRequest/error';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type PasswordChangeRequestBody = GoApiBody<'/change_password', 'POST'>;
+
+type PartialFormValue = PartialForm;
+const defaultFormValue: PartialFormValue = {};
+
+type FormSchema = ObjectSchema;
+type FormSchemaFields = ReturnType;
+
+interface Props {
+ handleModalCloseButton: () => void;
+}
+
+function ChangePasswordModal(props: Props) {
+ const {
+ handleModalCloseButton,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const alert = useAlert();
+
+ const getPasswordMatchCondition = useCallback((referenceVal: string | undefined) => {
+ function passwordMatchCondition(val: string | undefined) {
+ if (isTruthyString(val) && isTruthyString(referenceVal) && val !== referenceVal) {
+ return strings.changePasswordDoNotMatch;
+ }
+ return undefined;
+ }
+ return passwordMatchCondition;
+ }, [
+ strings.changePasswordDoNotMatch,
+ ]);
+
+ const formSchema: FormSchema = useMemo(() => (
+ {
+ fields: (value): FormSchemaFields => {
+ let fields: FormSchemaFields = {
+ old_password: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ },
+ new_password: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ },
+ confirmNewPassword: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ forceValue: undefinedValue,
+ },
+ };
+
+ fields = addCondition(
+ fields,
+ value,
+ ['new_password'],
+ ['confirmNewPassword'],
+ (val) => ({
+ confirmNewPassword: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ forceValue: undefinedValue,
+ validations: [getPasswordMatchCondition(val?.new_password)],
+ },
+ }),
+ );
+ return fields;
+ },
+ }
+ ), [getPasswordMatchCondition]);
+
+ const {
+ value: formValue,
+ error: formError,
+ setFieldValue,
+ setError,
+ validate,
+ } = useForm(formSchema, { value: defaultFormValue });
+
+ const {
+ pending: updatePasswordPending,
+ trigger: updatePassword,
+ } = useLazyRequest({
+ method: 'POST',
+ url: '/change_password',
+ body: (body: PasswordChangeRequestBody) => body,
+ onSuccess: () => {
+ alert.show(
+ strings.changePasswordSuccessMessage,
+ { variant: 'success' },
+ );
+ handleModalCloseButton();
+ },
+ onFailure: (error) => {
+ const {
+ value: {
+ formErrors,
+ },
+ } = error;
+
+ setError(transformObjectError(formErrors, () => undefined));
+
+ alert.show(
+ strings.changePasswordFailureMessage,
+ { variant: 'danger' },
+ );
+ },
+ });
+
+ const handleConfirmPasswordChange = useCallback((formValues: PartialFormValue) => {
+ const passwordFormValues = {
+ ...formValues,
+ };
+ updatePassword(passwordFormValues as PasswordChangeRequestBody);
+ }, [updatePassword]);
+
+ const handleSubmitPassword = createSubmitHandler(
+ validate,
+ setError,
+ handleConfirmPasswordChange,
+ );
+
+ const fieldError = getErrorObject(formError);
+
+ return (
+
+
+
+ >
+ )}
+ childrenContainerClassName={styles.content}
+ >
+
+
+
+
+
+ );
+}
+
+export default ChangePasswordModal;
diff --git a/src/views/AccountDetails/ChangePassword/styles.module.css b/app/src/views/AccountDetails/ChangePassword/styles.module.css
similarity index 100%
rename from src/views/AccountDetails/ChangePassword/styles.module.css
rename to app/src/views/AccountDetails/ChangePassword/styles.module.css
diff --git a/src/views/AccountDetails/EditAccountInfo/i18n.json b/app/src/views/AccountDetails/EditAccountInfo/i18n.json
similarity index 100%
rename from src/views/AccountDetails/EditAccountInfo/i18n.json
rename to app/src/views/AccountDetails/EditAccountInfo/i18n.json
diff --git a/app/src/views/AccountDetails/EditAccountInfo/index.tsx b/app/src/views/AccountDetails/EditAccountInfo/index.tsx
new file mode 100644
index 000000000..65f362666
--- /dev/null
+++ b/app/src/views/AccountDetails/EditAccountInfo/index.tsx
@@ -0,0 +1,321 @@
+import {
+ useCallback,
+ useContext,
+} from 'react';
+import {
+ Button,
+ Modal,
+ SelectInput,
+ TextInput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { stringValueSelector } from '@ifrc-go/ui/utils';
+import {
+ isDefined,
+ isFalsyString,
+} from '@togglecorp/fujs';
+import {
+ createSubmitHandler,
+ getErrorObject,
+ type ObjectSchema,
+ type PartialForm,
+ requiredStringCondition,
+ useForm,
+ useFormObject,
+} from '@togglecorp/toggle-form';
+
+import NonFieldError from '#components/NonFieldError';
+import DomainContext from '#contexts/domain';
+import UserContext from '#contexts/user';
+import useGlobalEnums from '#hooks/domain/useGlobalEnums';
+import useNationalSociety, { type NationalSociety } from '#hooks/domain/useNationalSociety';
+import useAlert from '#hooks/useAlert';
+import {
+ type GoApiBody,
+ type GoApiResponse,
+ useLazyRequest,
+} from '#utils/restRequest';
+import { transformObjectError } from '#utils/restRequest/error';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type UserMeResponse = GoApiResponse<'/api/v2/user/me/'>;
+type AccountRequestBody = GoApiBody<'/api/v2/user/{id}/', 'PATCH'>;
+type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>;
+type OrganizationTypeOption = {
+ key: NonNullable[number]['key'];
+ value: string;
+}
+
+const organizationTypeKeySelector = (item: OrganizationTypeOption) => item.key;
+const nsLabelSelector = (item: NationalSociety) => item.society_name;
+
+type PartialFormFields = PartialForm;
+
+type FormSchema = ObjectSchema;
+type FormSchemaFields = ReturnType
+
+type ProfileSchema = ObjectSchema;
+type ProfileSchemaFields = ReturnType
+
+const formSchema: FormSchema = {
+ fields: (): FormSchemaFields => ({
+ first_name: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ },
+ last_name: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ },
+ profile: {
+ fields: (): ProfileSchemaFields => ({
+ org: {
+ // FIXME: Server does not accept null as empty value
+ defaultValue: '',
+ },
+ org_type: {
+ // FIXME: Server does not accept null as empty value
+ defaultValue: '' as never,
+ },
+ city: {},
+ department: {},
+ position: {},
+ phone_number: {},
+ }),
+ },
+ }),
+};
+
+// FIXME: move this to utils
+function clearEmptyString(value: T | null | undefined) {
+ if (isFalsyString(value)) {
+ return undefined;
+ }
+ return value;
+}
+
+interface Props {
+ userDetails: UserMeResponse | undefined;
+ handleModalCloseButton: () => void;
+}
+
+function EditAccountInfo(props: Props) {
+ const {
+ userDetails,
+ handleModalCloseButton,
+ } = props;
+
+ const defaultFormValue: PartialFormFields = {
+ first_name: userDetails?.first_name,
+ last_name: userDetails?.last_name,
+ profile: {
+ city: userDetails?.profile?.city,
+ // FIXME: The server sends empty string as org_type
+ org_type: clearEmptyString(userDetails?.profile?.org_type),
+ // FIXME: The server sends empty string as org
+ org: clearEmptyString(userDetails?.profile?.org),
+ department: userDetails?.profile?.department,
+ phone_number: userDetails?.profile?.phone_number,
+ },
+ };
+
+ const strings = useTranslation(i18n);
+ const alert = useAlert();
+ const { userAuth } = useContext(UserContext);
+ const { invalidate } = useContext(DomainContext);
+ const { api_profile_org_types: organizationTypeOptions } = useGlobalEnums();
+
+ const {
+ value: formValue,
+ error: formError,
+ setFieldValue,
+ setError,
+ validate,
+ } = useForm(formSchema, { value: defaultFormValue });
+
+ const setProfileFieldValue = useFormObject<'profile', NonNullable>(
+ 'profile' as const,
+ setFieldValue,
+ {},
+ );
+
+ const {
+ pending: updateAccountPending,
+ trigger: updateAccountInfo,
+ } = useLazyRequest({
+ method: 'PATCH',
+ url: '/api/v2/user/{id}/',
+ pathVariables: userAuth && isDefined(userAuth.id)
+ ? { id: userAuth.id }
+ : undefined,
+ body: (body: AccountRequestBody) => body,
+ onSuccess: () => {
+ alert.show(
+ strings.editAccoutSuccessfulMessage,
+ { variant: 'success' },
+ );
+ invalidate('user-me');
+ handleModalCloseButton();
+ },
+ onFailure: (error) => {
+ const {
+ value: {
+ formErrors,
+ },
+ } = error;
+
+ setError(transformObjectError(formErrors, () => undefined));
+
+ alert.show(
+ strings.editAccountFailureMessage,
+ { variant: 'danger' },
+ );
+ },
+ });
+
+ const nationalSocietyOptions = useNationalSociety();
+
+ const handleConfirmProfileEdit = useCallback(
+ (formValues: PartialFormFields) => {
+ updateAccountInfo(formValues as AccountRequestBody);
+ },
+ [updateAccountInfo],
+ );
+
+ const handleOrganizationNameChange = useCallback(
+ (val: OrganizationTypeOption['key'] | undefined) => {
+ setProfileFieldValue(val, 'org_type');
+ if (val === 'NTLS') {
+ setProfileFieldValue(undefined, 'org');
+ }
+ },
+ [setProfileFieldValue],
+ );
+
+ const handleFormSubmit = createSubmitHandler(validate, setError, handleConfirmProfileEdit);
+
+ const fieldError = getErrorObject(formError);
+
+ const isNationalSociety = formValue.profile?.org_type === 'NTLS';
+
+ const profileError = getErrorObject(fieldError?.profile);
+
+ return (
+