Repository: Couchers-org/couchers (language: python)

Issue:
Settings page for notifications
See: https://next-console.couchershq.org/notifications

This should be brought into the web frontend.


Discussion / hints:
If we can get this in the Phase 3 milestone (pre-newsletter), that'd be great!

Fix patch:
diff --git a/app/backend/src/couchers/notifications/background.py b/app/backend/src/couchers/notifications/background.py
index 9b4b7e2799..bd676e09b1 100644
--- a/app/backend/src/couchers/notifications/background.py
+++ b/app/backend/src/couchers/notifications/background.py
@@ -57,7 +57,7 @@ def _send_email_notification(session, user: User, notification: Notification):
         plain_unsub_section += "This is a security email, you cannot unsubscribe from it."
         html_unsub_section = "This is a security email, you cannot unsubscribe from it."
     else:
-        manage_link = urls.account_settings_link()
+        manage_link = urls.notification_settings_link()
         plain_unsub_section += f"Edit your notification settings at <{manage_link}>"
         html_unsub_section = f'<a href="{manage_link}">Manage notification preferences</a>.'
         unsub_options = []
diff --git a/app/backend/src/couchers/urls.py b/app/backend/src/couchers/urls.py
index 98df4af0c1..d767d2d7af 100644
--- a/app/backend/src/couchers/urls.py
+++ b/app/backend/src/couchers/urls.py
@@ -39,6 +39,10 @@ def account_settings_link():
     return f"{config['BASE_URL']}/account-settings"
 
 
+def notification_settings_link():
+    return f"{config['BASE_URL']}/account-settings/notification-settings"
+
+
 def feature_preview_link():
     return f"{config['BASE_URL']}/preview"
 
diff --git a/app/web/components/CustomColorSwitch.tsx b/app/web/components/CustomColorSwitch.tsx
new file mode 100644
index 0000000000..9dfc237324
--- /dev/null
+++ b/app/web/components/CustomColorSwitch.tsx
@@ -0,0 +1,84 @@
+import { Switch } from "@material-ui/core";
+import classNames from "classnames";
+import makeStyles from "utils/makeStyles";
+
+import CircularProgress from "./CircularProgress";
+
+const useStyles = makeStyles(({ palette, shadows }) => ({
+  circle: {
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "center",
+    width: 20,
+    height: 20,
+    borderRadius: "50%",
+    backgroundColor: palette.grey[600],
+    boxShadow: shadows[1],
+  },
+  active: {
+    backgroundColor: palette.grey[600],
+  },
+  switchBase: {
+    color: palette.grey[600],
+    "& + .MuiSwitch-track": {
+      backgroundColor: palette.grey[200],
+    },
+    "&.Mui-checked": {
+      color: (props: { color: string }) => props.color,
+      "& + .MuiSwitch-track": {
+        backgroundColor: (props: { color: string }) => props.color,
+      },
+    },
+    "&.Mui-disabled": {
+      color: (props: { checked: boolean; color: string }) =>
+        props.checked ? props.color : palette.grey[600],
+      "& + .MuiSwitch-track": {
+        backgroundColor: (props: { checked: boolean; color: string }) =>
+          props.checked ? props.color : palette.grey[200],
+        opacity: 0.4,
+      },
+    },
+  },
+}));
+
+export default function CustomColorSwitch({
+  checked,
+  color,
+  isLoading,
+  onClick,
+  status,
+}: {
+  checked: boolean;
+  color: string;
+  isLoading: boolean;
+  onClick: () => void;
+  status: string;
+}) {
+  const classes = useStyles({ checked, color });
+
+  const Icon = () => (
+    <div
+      className={classNames(classes.circle, {
+        [classes.active]: checked && !isLoading,
+      })}
+      style={{ backgroundColor: checked ? color : undefined }}
+    >
+      {isLoading && (
+        <CircularProgress size={14} style={{ color: "white" }} thickness={6} />
+      )}
+    </div>
+  );
+
+  return (
+    <Switch
+      classes={{
+        switchBase: classes.switchBase,
+      }}
+      checked={checked}
+      checkedIcon={<Icon />}
+      disabled={isLoading || status === "loading"}
+      icon={<Icon />}
+      onClick={onClick}
+    />
+  );
+}
diff --git a/app/web/components/Icons/EventIcon.tsx b/app/web/components/Icons/EventIcon.tsx
new file mode 100644
index 0000000000..23b056154e
--- /dev/null
+++ b/app/web/components/Icons/EventIcon.tsx
@@ -0,0 +1,26 @@
+import { SvgIcon, SvgIconProps } from "@material-ui/core";
+
+export default function EventIcon(props: SvgIconProps) {
+  return (
+    <SvgIcon {...props} viewBox="0 0 37 37" xmlns="http://www.w3.org/2000/svg">
+      <svg
+        width="37"
+        height="37"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="M8 10C7.44772 10 7 10.4477 7 11C7 11.5523 7.44772 12 8 12H16C16.5523 12 17 11.5523 17 11C17 10.4477 16.5523 10 16 10H8Z"
+          fill="#767676"
+        />
+        <path
+          fillRule="evenodd"
+          clipRule="evenodd"
+          d="M6 3C4.34315 3 3 4.34315 3 6V18C3 19.6569 4.34315 21 6 21H18C19.6569 21 21 19.6569 21 18V6C21 4.34315 19.6569 3 18 3H6ZM5 18V8H19V18C19 18.5523 18.5523 19 18 19H6C5.44772 19 5 18.5523 5 18Z"
+          fill="#767676"
+        />
+      </svg>
+    </SvgIcon>
+  );
+}
diff --git a/app/web/components/Icons/NotificationNewIcon.tsx b/app/web/components/Icons/NotificationNewIcon.tsx
new file mode 100644
index 0000000000..9cb03ed7e9
--- /dev/null
+++ b/app/web/components/Icons/NotificationNewIcon.tsx
@@ -0,0 +1,24 @@
+import { SvgIcon, SvgIconProps } from "@material-ui/core";
+
+export default function NotificationNewIcon(props: SvgIconProps) {
+  return (
+    <SvgIcon {...props} viewBox="0 0 37 37" xmlns="http://www.w3.org/2000/svg">
+      <svg
+        width="37"
+        height="37"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="M19.5 12.4395V10.5H18V12.75C18 12.9489 18.0791 13.1396 18.2197 13.2803L20.25 15.3105V16.5H3.75V15.3105L5.78025 13.2803C5.92091 13.1396 5.99996 12.9489 6 12.75V9.75C5.99791 8.6961 6.27398 7.6603 6.80032 6.74724C7.32665 5.83417 8.08463 5.07618 8.99767 4.5498C9.91072 4.02343 10.9465 3.74733 12.0004 3.74938C13.0543 3.75144 14.089 4.03157 15 4.5615V2.88525C14.2861 2.56914 13.5267 2.36766 12.75 2.28825V0.75H11.25V2.2875C9.40093 2.4757 7.68732 3.34284 6.44053 4.72124C5.19373 6.09964 4.50233 7.89138 4.5 9.75V12.4395L2.46975 14.4697C2.32909 14.6104 2.25004 14.8011 2.25 15V17.25C2.25 17.4489 2.32902 17.6397 2.46967 17.7803C2.61032 17.921 2.80109 18 3 18H8.25V18.75C8.25 19.7446 8.64509 20.6984 9.34835 21.4016C10.0516 22.1049 11.0054 22.5 12 22.5C12.9946 22.5 13.9484 22.1049 14.6517 21.4016C15.3549 20.6984 15.75 19.7446 15.75 18.75V18H21C21.1989 18 21.3897 17.921 21.5303 17.7803C21.671 17.6397 21.75 17.4489 21.75 17.25V15C21.75 14.8011 21.6709 14.6104 21.5303 14.4697L19.5 12.4395ZM14.25 18.75C14.25 19.3467 14.0129 19.919 13.591 20.341C13.169 20.7629 12.5967 21 12 21C11.4033 21 10.831 20.7629 10.409 20.341C9.98705 19.919 9.75 19.3467 9.75 18.75V18H14.25V18.75Z"
+          fill="#767676"
+        />
+        <path
+          d="M19.5 9C21.1569 9 22.5 7.65685 22.5 6C22.5 4.34315 21.1569 3 19.5 3C17.8431 3 16.5 4.34315 16.5 6C16.5 7.65685 17.8431 9 19.5 9Z"
+          fill="#767676"
+        />
+      </svg>
+    </SvgIcon>
+  );
+}
diff --git a/app/web/components/Icons/PenIcon.tsx b/app/web/components/Icons/PenIcon.tsx
new file mode 100644
index 0000000000..29229cb065
--- /dev/null
+++ b/app/web/components/Icons/PenIcon.tsx
@@ -0,0 +1,26 @@
+import { SvgIcon, SvgIconProps } from "@material-ui/core";
+
+export default function EventIcon(props: SvgIconProps) {
+  return (
+    <SvgIcon {...props} viewBox="0 0 37 37" xmlns="http://www.w3.org/2000/svg">
+      <svg
+        width="37"
+        height="37"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          fillRule="evenodd"
+          clipRule="evenodd"
+          d="M21.2635 2.29289C20.873 1.90237 20.2398 1.90237 19.8493 2.29289L18.9769 3.16525C17.8618 2.63254 16.4857 2.82801 15.5621 3.75165L4.95549 14.3582L10.6123 20.0151L21.2189 9.4085C22.1426 8.48486 22.338 7.1088 21.8053 5.99367L22.6777 5.12132C23.0682 4.7308 23.0682 4.09763 22.6777 3.70711L21.2635 2.29289ZM16.9955 10.8035L10.6123 17.1867L7.78392 14.3582L14.1671 7.9751L16.9955 10.8035ZM18.8138 8.98525L19.8047 7.99429C20.1953 7.60376 20.1953 6.9706 19.8047 6.58007L18.3905 5.16586C18 4.77534 17.3668 4.77534 16.9763 5.16586L15.9853 6.15683L18.8138 8.98525Z"
+          fill="#767676"
+        />
+        <path
+          d="M2 22.9502L4.12171 15.1717L9.77817 20.8289L2 22.9502Z"
+          fill="#767676"
+        />
+      </svg>
+    </SvgIcon>
+  );
+}
diff --git a/app/web/components/Icons/index.ts b/app/web/components/Icons/index.ts
index 352c7dfc56..cf5b043405 100644
--- a/app/web/components/Icons/index.ts
+++ b/app/web/components/Icons/index.ts
@@ -1,57 +1,70 @@
+/* eslint-disable simple-import-sort/exports */
+// Please keep alphabetical order :)
+export { default as AccountSecurityIcon } from "@material-ui/icons/VerifiedUser";
+export { default as AccountSettingsIcon } from "@material-ui/icons/SettingsApplications";
 export { default as CommunityIcon } from "./CommunityIcon";
-export { default as AttendeesIcon } from "@material-ui/icons/AccountCircleOutlined";
+export { default as EventIcon } from "./EventIcon";
+export { default as NotificationNewIcon } from "./NotificationNewIcon";
+
 export { default as AddIcon } from "@material-ui/icons/AddOutlined";
+export { default as AttendeesIcon } from "@material-ui/icons/AccountCircleOutlined";
 export { default as BackIcon } from "@material-ui/icons/ArrowBackOutlined";
 export { default as BugIcon } from "@material-ui/icons/BugReportOutlined";
 export { default as CakeIcon } from "@material-ui/icons/CakeOutlined";
+export { default as CalendarIcon } from "@material-ui/icons/EventOutlined";
+export { default as ChatBubbleIcon } from "@material-ui/icons/ChatBubble";
 export { default as CheckCircleIcon } from "@material-ui/icons/CheckCircle";
 export { default as CheckIcon } from "@material-ui/icons/CheckOutlined";
+export { default as ClockIcon } from "@material-ui/icons/ScheduleOutlined";
 export {
   default as CloseIcon,
   default as CrossIcon,
 } from "@material-ui/icons/CloseOutlined";
+export { default as CommunityLeadersIcon } from "@material-ui/icons/SportsSoccerOutlined";
+export { default as CouchFilledIcon } from "@material-ui/icons/Weekend";
+export { default as CouchIcon } from "@material-ui/icons/HotelOutlined";
+export { default as DissatisfiedIcon } from "@material-ui/icons/SentimentDissatisfiedOutlined";
 export { default as DoneAllIcon } from "@material-ui/icons/DoneAllOutlined";
 export { default as DoneIcon } from "@material-ui/icons/DoneOutlined";
 export { default as EditIcon } from "@material-ui/icons/EditOutlined";
 export { default as EmailIcon } from "@material-ui/icons/EmailOutlined";
-export { default as PersonIcon } from "@material-ui/icons/EmojiPeopleOutlined";
 export { default as ErrorIcon } from "@material-ui/icons/Error";
-export { default as CalendarIcon } from "@material-ui/icons/EventOutlined";
 export { default as ExpandLessIcon } from "@material-ui/icons/ExpandLessOutlined";
 export { default as ExpandMoreIcon } from "@material-ui/icons/ExpandMoreOutlined";
+export { default as ExtremelyDissatisfiedIcon } from "@material-ui/icons/SentimentVeryDissatisfiedOutlined";
+export { default as ExtremelySatisfiedIcon } from "@material-ui/icons/SentimentVerySatisfiedOutlined";
 export { default as FilterIcon } from "@material-ui/icons/FilterListOutlined";
 export { default as FlagIcon } from "@material-ui/icons/Flag";
 export { default as GithubIcon } from "@material-ui/icons/GitHub";
+export { default as GlobeIcon } from "@material-ui/icons/Language";
 export { default as HelpIcon } from "@material-ui/icons/Help";
-export { default as CouchIcon } from "@material-ui/icons/HotelOutlined";
 export { default as InfoIcon } from "@material-ui/icons/InfoOutlined";
-export { default as GlobeIcon } from "@material-ui/icons/Language";
 export { default as LanguageIcon } from "@material-ui/icons/LanguageOutlined";
 export { default as LinkedInIcon } from "@material-ui/icons/LinkedIn";
 export { default as LinkIcon } from "@material-ui/icons/LinkOutlined";
-export { default as PinIcon } from "@material-ui/icons/LocationOn";
 export { default as LocationIcon } from "@material-ui/icons/LocationOnOutlined";
 export { default as MenuIcon } from "@material-ui/icons/Menu";
-export { default as VeryDissatisfiedIcon } from "@material-ui/icons/MoodBadOutlined";
-export { default as VerySatisfiedIcon } from "@material-ui/icons/MoodOutlined";
 export {
   default as MoreIcon,
   default as QuestionIcon,
 } from "@material-ui/icons/MoreHorizOutlined";
-export { default as OverflowMenuIcon } from "@material-ui/icons/MoreVertOutlined";
+export { default as MuteIcon } from "@material-ui/icons/VolumeOffOutlined";
 export { default as OpenInNewIcon } from "@material-ui/icons/OpenInNew";
+export { default as OverflowMenuIcon } from "@material-ui/icons/MoreVertOutlined";
+export { default as PenIcon } from "./PenIcon";
 export { default as PeopleIcon } from "@material-ui/icons/PeopleOutlined";
 export { default as PersonAddIcon } from "@material-ui/icons/PersonAddOutlined";
-export { default as ClockIcon } from "@material-ui/icons/ScheduleOutlined";
+export { default as PersonIcon } from "@material-ui/icons/EmojiPeopleOutlined";
+export { default as PinIcon } from "@material-ui/icons/LocationOn";
+export { default as ReminderIcon } from "@material-ui/icons/Notifications";
+export { default as SatisfiedIcon } from "@material-ui/icons/SentimentSatisfiedAltOutlined";
 export { default as SearchIcon } from "@material-ui/icons/SearchOutlined";
+export { default as SettingsIcon } from "@material-ui/icons/SettingsOutlined";
+export { default as SinglePersonIcon } from "@material-ui/icons/Person";
 export { default as SlightlyDissatisfiedIcon } from "@material-ui/icons/SentimentDissatisfied";
-export { default as DissatisfiedIcon } from "@material-ui/icons/SentimentDissatisfiedOutlined";
 export { default as SlightlySatisfiedIcon } from "@material-ui/icons/SentimentSatisfied";
-export { default as SatisfiedIcon } from "@material-ui/icons/SentimentSatisfiedAltOutlined";
-export { default as ExtremelyDissatisfiedIcon } from "@material-ui/icons/SentimentVeryDissatisfiedOutlined";
-export { default as ExtremelySatisfiedIcon } from "@material-ui/icons/SentimentVerySatisfiedOutlined";
-export { default as SettingsIcon } from "@material-ui/icons/SettingsOutlined";
-export { default as CommunityLeadersIcon } from "@material-ui/icons/SportsSoccerOutlined";
+export { default as VeryDissatisfiedIcon } from "@material-ui/icons/MoodBadOutlined";
+export { default as VerySatisfiedIcon } from "@material-ui/icons/MoodOutlined";
 export { default as VerifiedUser } from "@material-ui/icons/VerifiedUser";
-export { default as MuteIcon } from "@material-ui/icons/VolumeOffOutlined";
 export { default as WorkIcon } from "@material-ui/icons/WorkOutlined";
+/* eslint-enable simple-import-sort/exports */
diff --git a/app/web/features/auth/Settings.tsx b/app/web/features/auth/Settings.tsx
index f3609ac1e3..a878fead71 100644
--- a/app/web/features/auth/Settings.tsx
+++ b/app/web/features/auth/Settings.tsx
@@ -39,22 +39,15 @@ export default function Settings() {
     <>
       <HtmlMeta title={t("account_settings_page.title")} />
       <PageTitle>{t("account_settings_page.title")}</PageTitle>
-      <Section
-        className={classes.section}
-        title={t("account_settings_page.gender_section.title")}
-        content={t("account_settings_page.gender_section.explanation")}
-      />
-      <Section
-        className={classes.section}
-        title={t("account_settings_page.birth_date_section.title")}
-        content={t("account_settings_page.birth_date_section.explanation")}
-      />
       {isAccountInfoLoading ? (
         <CircularProgress />
       ) : accountInfoError ? (
         <Alert severity="error">{accountInfoError.message}</Alert>
       ) : accountInfo ? (
         <>
+          <NotificationSettings className={classes.section} />
+          <ChangeEmail className={classes.section} email={accountInfo.email} />
+          <ChangePassword className={classes.section} />
           <Username
             className={classes.section}
             username={accountInfo.username}
@@ -63,10 +56,17 @@ export default function Settings() {
             className={classes.section}
             timezone={accountInfo.timezone}
           />
-          <ChangeEmail className={classes.section} email={accountInfo.email} />
-          <NotificationSettings className={classes.section} />
           <DoNotEmail className={classes.section} />
-          <ChangePassword className={classes.section} />
+          <Section
+            className={classes.section}
+            title={t("account_settings_page.gender_section.title")}
+            content={t("account_settings_page.gender_section.explanation")}
+          />
+          <Section
+            className={classes.section}
+            title={t("account_settings_page.birth_date_section.title")}
+            content={t("account_settings_page.birth_date_section.explanation")}
+          />
           <DeleteAccount
             className={classes.section}
             username={accountInfo.username}
diff --git a/app/web/features/auth/locales/de.json b/app/web/features/auth/locales/de.json
index a0c9464082..848e4eaef6 100644
--- a/app/web/features/auth/locales/de.json
+++ b/app/web/features/auth/locales/de.json
@@ -123,7 +123,97 @@
     }
   },
   "notification_settings": {
-    "title": "Benachrichtigungseinstellungen"
+    "title": "Benachrichtigungseinstellungen",
+    "edit_in_console": "Sie können Ihre Benachrichtigungseinstellungen <2>auf dieser Seite</2> ändern. Wir werden dies bald hierhin verlegen.",
+    "go_to_button": "Benachrichtigungseinstellungen verwalten",
+    "edit_preferences": {
+      "title": "Benachrichtigungseinstellungen",
+      "description": "Sie erhalten möglicherweise weiterhin wichtige Benachrichtigungen über Ihr Konto und Inhalte außerhalb Ihrer bevorzugten Benachrichtigungseinstellungen.",
+      "list_heading": "Benachrichtigungen, die Sie erhalten können",
+      "error_loading": "Fehler beim Laden der Benachrichtigungseinstellungen",
+      "list_items": {
+        "account_security": "Kontosicherheit",
+        "account_settings": "Kontoeinstellungen",
+        "chat": "Nachrichten",
+        "event": "Veranstaltungen",
+        "friend_request": "Freundschaftsanfragen",
+        "host_request": "Gastgeberanfragen",
+        "reference": "Referenzen"
+      },
+      "item_descriptions": {
+        "host_request": {
+          "create": "Jemand sendet Ihnen eine Gastgeberanfrage",
+          "accept": "Jemand akzeptiert Ihre Gastgeberanfrage",
+          "confirm": "Jemand bestätigt seine Gastgeberanfrage",
+          "reject": "Jemand lehnt Ihre Gastgeberanfrage ab",
+          "cancel": "Jemand storniert seine Gastgeberanfrage",
+          "message": "Jemand sendet eine Nachricht in einer Gastgeberanfrage",
+          "missed_messages": "Sie haben Nachrichten in einer Gastgeberanfrage verpasst"
+        },
+        "chat": {
+          "message": "Jemand sendet Ihnen eine Nachricht",
+          "missed_messages": "Sie haben Nachrichten in einem Chat verpasst"
+        },
+        "reference": {
+          "receive_hosted": "Sie erhalten eine Referenz von jemandem, der Sie beherbergt hat",
+          "receive_surfed": "Sie erhalten eine Referenz von jemandem, den Sie beherbergt haben",
+          "receive_friend": "Sie erhalten eine Referenz von einem Freund",
+          "reminder_hosted": "Erinnerung, eine Referenz für jemanden zu schreiben, den Sie beherbergt haben",
+          "reminder_surfed": "Erinnerung, eine Referenz für jemanden zu schreiben, mit dem Sie übernachtet haben"
+        },
+        "friend_request": {
+          "create": "Jemand sendet Ihnen eine Freundschaftsanfrage",
+          "accept": "Jemand akzeptiert Ihre Freundschaftsanfrage"
+        },
+        "event": {
+          "create_approved": "Eine von den Moderatoren genehmigte Veranstaltung wird in Ihrer Community erstellt",
+          "create_any": "Ein Benutzer erstellt eine beliebige Veranstaltung in Ihrer Community (nicht von einem Administrator überprüft)",
+          "update": "Eine Veranstaltung, an der Sie teilnehmen, wird aktualisiert",
+          "cancel": "Eine Veranstaltung, an der Sie teilnehmen, wird abgesagt",
+          "delete": "Eine Veranstaltung, an der Sie teilnehmen, wird gelöscht",
+          "invite_organizer": "Jemand lädt Sie ein, eine Veranstaltung mit zu organisieren"
+        },
+        "onboarding": {
+          "reminder": "Erinnerung, Ihr Profil nach der Anmeldung zu vervollständigen"
+        },
+        "badge": {
+          "add": "Ein Abzeichen wird Ihrem Konto hinzugefügt",
+          "remove": "Ein Abzeichen wird von Ihrem Konto entfernt"
+        },
+        "donation": {
+          "received": "Ihre Spende wurde erhalten"
+        },
+        "password": {
+          "change": "Ihr Passwort wurde geändert"
+        },
+        "password_reset": {
+          "start": "Passwortzurücksetzung wurde eingeleitet",
+          "complete": "Passwortzurücksetzung wurde abgeschlossen"
+        },
+        "email_address": {
+          "change": "E-Mail-Änderung wurde eingeleitet",
+          "verify": "Ihre neue E-Mail wurde verifiziert"
+        },
+        "account_deletion": {
+          "start": "Sie haben die Kontolöschung eingeleitet",
+          "complete": "Ihr Konto wurde gelöscht",
+          "recovered": "Ihr Konto wurde wiederhergestellt (nicht gelöscht)"
+        },
+        "api_key": {
+          "create": "Ein API-Schlüssel wurde für Ihr Konto erstellt"
+        },
+        "phone_number": {
+          "change": "Ihre Telefonnummer wurde geändert",
+          "verify": "Ihre Telefonnummer wurde verifiziert"
+        },
+        "birthdate": {
+          "change": "Ihr Geburtsdatum wurde geändert"
+        },
+        "gender": {
+          "change": "Das auf Ihrem Profil angezeigte Geschlecht wurde geändert"
+        }
+      }
+    }
   },
   "feature_preview": {
     "disclaimer": "Bitte beachte, dass diese Features experimentell sind. Sie werden vielleicht nicht wie vorhergesehen funktionieren und sich während der Entwicklung schnell ändern. Bitte gib uns Feedback dazu in unserem Forum und melde uns Bugs!",
diff --git a/app/web/features/auth/locales/en.json b/app/web/features/auth/locales/en.json
index babbfe03d9..d59a050f3c 100644
--- a/app/web/features/auth/locales/en.json
+++ b/app/web/features/auth/locales/en.json
@@ -182,7 +182,95 @@
   "notification_settings": {
     "title": "Notification settings",
     "edit_in_console": "You can change your notification settings <2>on this page</2>. We will soon move it here.",
-    "go_to_button": "Manage notification settings"
+    "go_to_button": "Manage notification settings",
+    "edit_preferences": {
+      "title": "Notification Settings",
+      "description": "You may still receive important notifications about your account and content outside of your preferred notification settings.",
+      "list_heading": "Notifications you may receive",
+      "error_loading": "Error loading notification settings",
+      "list_items": {
+        "account_security": "Account security",
+        "account_settings": "Account settings",
+        "chat": "Messages",
+        "event": "Events",
+        "friend_request": "Friend requests",
+        "host_request": "Host requests",
+        "reference": "References"
+      },
+      "item_descriptions": {
+        "host_request": {
+          "create": "Someone sends you a host request",
+          "accept": "Someone accepts your host request",
+          "confirm": "Someone confirms their host request",
+          "reject": "Someone declines your host request",
+          "cancel": "Someone cancels their host request",
+          "message": "Someone sends a message in a host request",
+          "missed_messages": "You miss messages in a host request"
+        },
+        "chat": {
+          "message": "Someone sends you a message",
+          "missed_messages": "You miss messages in a chat"
+        },
+        "reference": {
+          "receive_hosted": "You receive a reference from someone who hosted you",
+          "receive_surfed": "You receive a reference from someone you hosted",
+          "receive_friend": "You received a reference from a friend",
+          "reminder_hosted": "Reminder to write a reference to someone you hosted",
+          "reminder_surfed": "Reminder to write a reference to someone you surfed with"
+        },
+        "friend_request": {
+          "create": "Someone sends you a friend request",
+          "accept": "Someone accepts your friend request"
+        },
+        "event": {
+          "create_approved": "An event that is approved by the moderators is created in your community",
+          "create_any": "A user creates any event in your community (not checked by an admin)",
+          "update": "An event you are attending is updated",
+          "cancel": "An event you are attending is cancelled",
+          "delete": "An event you are attending is deleted",
+          "invite_organizer": "Someone invites you to co-organize an event"
+        },
+        "onboarding": {
+          "reminder": "Reminder to complete your profile after signing up"
+        },
+        "badge": {
+          "add": "A badge is added to your account",
+          "remove": "A badge is removed from your account"
+        },
+        "donation": {
+          "received": "Your donation is received"
+        },
+        "password": {
+          "change": "Your password is changed"
+        },
+        "password_reset": {
+          "start": "Password reset is initiated",
+          "complete": "Password reset is completed"
+        },
+        "email_address": {
+          "change": "Email change is initiated",
+          "verify": "Your new email is verified"
+        },
+        "account_deletion": {
+          "start": "You initiate account deletion",
+          "complete": "Your account is deleted",
+          "recovered": "Your account is recovered (undeleted)"
+        },
+        "api_key": {
+          "create": "An API key is created for your account"
+        },
+        "phone_number": {
+          "change": "Your phone number is changed",
+          "verify": "Your phone number is verified"
+        },
+        "birthdate": {
+          "change": "Your birthdate is changed"
+        },
+        "gender": {
+          "change": "The gender displayed on your profile is changed"
+        }
+      }
+    }
   },
   "strong_verification": {
     "title": "Strong Verification",
diff --git a/app/web/features/auth/locales/es.json b/app/web/features/auth/locales/es.json
index 14678d7f4b..f308594f8c 100644
--- a/app/web/features/auth/locales/es.json
+++ b/app/web/features/auth/locales/es.json
@@ -159,7 +159,97 @@
     "disclaimer": "Por favor, ten en cuenta que estas características son experimentales. Podrían no funcionar según lo previsto y podrían cambiar rápidamente según las desarrollamos. Siéntete libre de dar tu opinión en nuestro forum. ¡Por favor, reporta cualquier error!"
   },
   "notification_settings": {
-    "title": "Configuración de notificaciones"
+    "title": "Configuración de notificaciones",
+    "edit_in_console": "Puedes cambiar tu configuración de notificaciones <2>en esta página</2>. Pronto lo moveremos aquí.",
+    "go_to_button": "Gestionar configuración de notificaciones",
+    "edit_preferences": {
+      "title": "Configuración de notificaciones",
+      "description": "Aún puedes recibir notificaciones importantes sobre tu cuenta y contenido fuera de tus configuraciones de notificaciones preferidas.",
+      "list_heading": "Notificaciones que puedes recibir",
+      "error_loading": "Error al cargar la configuración de notificaciones",
+      "list_items": {
+        "account_security": "Seguridad de la cuenta",
+        "account_settings": "Configuración de la cuenta",
+        "chat": "Mensajes",
+        "event": "Eventos",
+        "friend_request": "Solicitudes de amistad",
+        "host_request": "Solicitudes de anfitrión",
+        "reference": "Referencias"
+      },
+      "item_descriptions": {
+        "host_request": {
+          "create": "Alguien te envía una solicitud de anfitrión",
+          "accept": "Alguien acepta tu solicitud de anfitrión",
+          "confirm": "Alguien confirma su solicitud de anfitrión",
+          "reject": "Alguien rechaza tu solicitud de anfitrión",
+          "cancel": "Alguien cancela su solicitud de anfitrión",
+          "message": "Alguien envía un mensaje en una solicitud de anfitrión",
+          "missed_messages": "Pierdes mensajes en una solicitud de anfitrión"
+        },
+        "chat": {
+          "message": "Alguien te envía un mensaje",
+          "missed_messages": "Pierdes mensajes en un chat"
+        },
+        "reference": {
+          "receive_hosted": "Recibes una referencia de alguien que te hospedó",
+          "receive_surfed": "Recibes una referencia de alguien a quien hospedaste",
+          "receive_friend": "Recibes una referencia de un amigo",
+          "reminder_hosted": "Recordatorio para escribir una referencia a alguien a quien hospedaste",
+          "reminder_surfed": "Recordatorio para escribir una referencia a alguien con quien surfeaste"
+        },
+        "friend_request": {
+          "create": "Alguien te envía una solicitud de amistad",
+          "accept": "Alguien acepta tu solicitud de amistad"
+        },
+        "event": {
+          "create_approved": "Se crea un evento aprobado por los moderadores en tu comunidad",
+          "create_any": "Un usuario crea cualquier evento en tu comunidad (no revisado por un administrador)",
+          "update": "Se actualiza un evento al que estás asistiendo",
+          "cancel": "Se cancela un evento al que estás asistiendo",
+          "delete": "Se elimina un evento al que estás asistiendo",
+          "invite_organizer": "Alguien te invita a coorganizar un evento"
+        },
+        "onboarding": {
+          "reminder": "Recordatorio para completar tu perfil después de registrarte"
+        },
+        "badge": {
+          "add": "Se agrega una insignia a tu cuenta",
+          "remove": "Se elimina una insignia de tu cuenta"
+        },
+        "donation": {
+          "received": "Tu donación ha sido recibida"
+        },
+        "password": {
+          "change": "Tu contraseña ha sido cambiada"
+        },
+        "password_reset": {
+          "start": "Se inicia el restablecimiento de la contraseña",
+          "complete": "El restablecimiento de la contraseña se ha completado"
+        },
+        "email_address": {
+          "change": "Se inicia el cambio de correo electrónico",
+          "verify": "Tu nuevo correo electrónico ha sido verificado"
+        },
+        "account_deletion": {
+          "start": "Inicias la eliminación de la cuenta",
+          "complete": "Tu cuenta ha sido eliminada",
+          "recovered": "Tu cuenta ha sido recuperada (no eliminada)"
+        },
+        "api_key": {
+          "create": "Se crea una clave API para tu cuenta"
+        },
+        "phone_number": {
+          "change": "Tu número de teléfono ha sido cambiado",
+          "verify": "Tu número de teléfono ha sido verificado"
+        },
+        "birthdate": {
+          "change": "Tu fecha de nacimiento ha sido cambiada"
+        },
+        "gender": {
+          "change": "El género mostrado en tu perfil ha sido cambiado"
+        }
+      }
+    }
   },
   "unsubscribe": {
     "button_text": "Confirmar anulación de suscripción",
diff --git a/app/web/features/auth/notifications/EditNotificationSettingsPage.stories.tsx b/app/web/features/auth/notifications/EditNotificationSettingsPage.stories.tsx
new file mode 100644
index 0000000000..4240d79c46
--- /dev/null
+++ b/app/web/features/auth/notifications/EditNotificationSettingsPage.stories.tsx
@@ -0,0 +1,38 @@
+import { ThemeProvider } from "@material-ui/core/styles";
+import { Meta, Story } from "@storybook/react";
+import { GetNotificationSettingsRes } from "proto/notifications_pb";
+import React from "react";
+import { I18nextProvider } from "react-i18next";
+import { mockedService } from "stories/serviceMocks";
+import notifications from "test/fixtures/notifications.json";
+import i18n from "test/i18n";
+import { theme } from "theme";
+
+import EditNotificationSettingsPage from "./EditNotificationSettingsPage";
+
+export default {
+  title: "Features/Auth/Notifications/EditNotificationSettingsPage",
+  component: EditNotificationSettingsPage,
+  decorators: [
+    (Story) => (
+      <I18nextProvider i18n={i18n}>
+        <ThemeProvider theme={theme}>
+          <Story />
+        </ThemeProvider>
+      </I18nextProvider>
+    ),
+  ],
+} as Meta;
+
+const Template: Story = () => {
+  setMocks();
+  return <EditNotificationSettingsPage />;
+};
+
+export const Default = Template.bind({});
+
+function setMocks() {
+  mockedService.notifications.getNotificationSettings =
+    (): Promise<GetNotificationSettingsRes.AsObject> =>
+      Promise.resolve(notifications);
+}
diff --git a/app/web/features/auth/notifications/EditNotificationSettingsPage.tsx b/app/web/features/auth/notifications/EditNotificationSettingsPage.tsx
new file mode 100644
index 0000000000..665496bbc4
--- /dev/null
+++ b/app/web/features/auth/notifications/EditNotificationSettingsPage.tsx
@@ -0,0 +1,140 @@
+import { CircularProgress, List, Typography } from "@material-ui/core";
+import Snackbar from "components/Snackbar";
+import { AUTH } from "i18n/namespaces";
+import { useTranslation } from "next-i18next";
+import { useEffect, useState } from "react";
+import makeStyles from "utils/makeStyles";
+
+import NotificationSettingsListItem from "./NotificationSettingsListItem";
+import useNotificationSettings from "./useNotificationSettings";
+
+export type NotificationType =
+  | "account_security"
+  | "account_settings"
+  | "chat"
+  | "event"
+  | "reference"
+  | "friend_request"
+  | "host_request";
+
+export interface GroupAction {
+  action: string;
+  description: string;
+  email: boolean;
+  push: boolean;
+  topic: string;
+  userEditable: boolean;
+}
+
+interface GroupsByType {
+  [key: string]: GroupAction[];
+}
+
+const useStyles = makeStyles((theme) => ({
+  descriptionText: {
+    fontSize: theme.spacing(1.8),
+    color: theme.palette.text.secondary,
+  },
+  list: {
+    border: `1px solid ${theme.palette.divider}`,
+    marginTop: theme.spacing(1),
+    display: "flex",
+    flexDirection: "column",
+    padding: `0 ${theme.spacing(1)}`,
+  },
+  loading: {
+    position: "absolute",
+  },
+  notificationSettingsContainer: {
+    display: "flex",
+    flexDirection: "column",
+    padding: theme.spacing(4),
+    margin: "0 auto",
+    width: "100%",
+    [theme.breakpoints.up("md")]: {
+      width: "50%",
+    },
+  },
+  notificationDescription: {
+    margin: theme.spacing(1, 0),
+    paddingBottom: theme.spacing(3),
+  },
+}));
+
+export default function EditNotificationSettingsPage() {
+  const classes = useStyles();
+  const { t } = useTranslation(AUTH, {
+    keyPrefix: "notification_settings.edit_preferences",
+  });
+  const { data, isLoading, isError } = useNotificationSettings();
+  const [groups, setGroups] = useState<GroupsByType>({});
+  const [areGroupsLoading, setAreGroupsLoading] = useState<boolean>(true);
+
+  useEffect(() => {
+    if (!data) {
+      return;
+    }
+
+    const computedGroups = data?.groupsList.reduce<GroupsByType>(
+      (acc, group) => {
+        group.topicsList.forEach((topic) => {
+          if (topic && topic.itemsList) {
+            topic.itemsList.forEach((subTopic) => {
+              const key =
+                group.heading === "Account Security"
+                  ? "account_security"
+                  : group.heading === "Account Settings"
+                  ? "account_settings"
+                  : topic.topic;
+
+              if (!acc[key]) {
+                acc[key] = [];
+              }
+
+              if (subTopic.userEditable) {
+                acc[key].push({ ...subTopic, topic: topic.topic });
+              }
+            });
+          }
+        });
+
+        return acc;
+      },
+      {}
+    );
+
+    setGroups(computedGroups);
+    setAreGroupsLoading(false);
+  }, [data]);
+
+  const renderNotificationListItems = () =>
+    Object.keys(groups)
+      .filter((key) => groups[key].length > 0)
+      .map((key) => (
+        <NotificationSettingsListItem
+          key={key}
+          items={groups[key]}
+          type={key as NotificationType}
+        />
+      ));
+
+  return (
+    <div className={classes.notificationSettingsContainer}>
+      <Typography variant="h2">{t("title")}</Typography>
+      <Typography className={classes.notificationDescription} variant="body1">
+        {t("description")}
+      </Typography>
+      <Typography variant="h3">{t("list_heading")}</Typography>
+      {isError && (
+        <Snackbar severity="error">
+          <Typography>{t("error_loading")}</Typography>
+        </Snackbar>
+      )}
+      {!isLoading && !areGroupsLoading ? (
+        <List className={classes.list}>{renderNotificationListItems()}</List>
+      ) : (
+        <CircularProgress className={classes.loading} />
+      )}
+    </div>
+  );
+}
diff --git a/app/web/features/auth/notifications/NotificationSettings.tsx b/app/web/features/auth/notifications/NotificationSettings.tsx
index 5086c3c08f..b9bbdb03c2 100644
--- a/app/web/features/auth/notifications/NotificationSettings.tsx
+++ b/app/web/features/auth/notifications/NotificationSettings.tsx
@@ -1,10 +1,9 @@
-import { Link, Typography } from "@material-ui/core";
+import { Typography } from "@material-ui/core";
 import Button from "components/Button";
-import { Trans, useTranslation } from "i18n";
+import { useTranslation } from "i18n";
 import { AUTH } from "i18n/namespaces";
-
-const NOTIFICATION_SETTINGS_URL =
-  process.env.NEXT_PUBLIC_CONSOLE_BASE_URL + "/notifications";
+import Link from "next/link";
+import { notificationSettingsRoute } from "routes";
 
 export default function NotificationSettings({
   className,
@@ -16,16 +15,9 @@ export default function NotificationSettings({
   return (
     <div className={className}>
       <Typography variant="h2">{t("notification_settings.title")}</Typography>
-      <Typography variant="body1">
-        <Trans t={t} i18nKey="notification_settings.edit_in_console">
-          You can change your notification settings{" "}
-          <Link href={NOTIFICATION_SETTINGS_URL}>on this page</Link>. We will
-          soon move it here.
-        </Trans>
-      </Typography>
-      <Button href={NOTIFICATION_SETTINGS_URL}>
-        {t("notification_settings.go_to_button")}
-      </Button>
+      <Link href={notificationSettingsRoute} passHref>
+        <Button>{t("notification_settings.go_to_button")}</Button>
+      </Link>
     </div>
   );
 }
diff --git a/app/web/features/auth/notifications/NotificationSettingsListItem.stories.tsx b/app/web/features/auth/notifications/NotificationSettingsListItem.stories.tsx
new file mode 100644
index 0000000000..4a5974735f
--- /dev/null
+++ b/app/web/features/auth/notifications/NotificationSettingsListItem.stories.tsx
@@ -0,0 +1,107 @@
+import { ThemeProvider } from "@material-ui/core/styles";
+import { Meta, Story } from "@storybook/react";
+import React from "react";
+import { I18nextProvider } from "react-i18next";
+import i18n from "test/i18n";
+import { theme } from "theme";
+
+import NotificationSettingsListItem, {
+  NotificationSettingsListItemProps,
+} from "./NotificationSettingsListItem";
+
+const mockItems = [
+  {
+    action: "create",
+    description: "Someone sends you a host request",
+    userEditable: true,
+    push: false,
+    digest: false,
+    email: true,
+    topic: "host_request",
+  },
+
+  {
+    action: "accept",
+    description: "Someone accepts your host request",
+    userEditable: true,
+    push: true,
+    digest: false,
+    email: true,
+    topic: "host_request",
+  },
+  {
+    action: "confirm",
+    description: "Someone confirms their host request",
+    userEditable: true,
+    push: true,
+    digest: false,
+    email: true,
+    topic: "host_request",
+  },
+  {
+    action: "reject",
+    description: "Someone declines your host request",
+    userEditable: true,
+    push: true,
+    digest: false,
+    email: true,
+    topic: "host_request",
+  },
+  {
+    action: "cancel",
+    description: "Someone cancels their host request",
+    userEditable: true,
+    push: true,
+    digest: false,
+    email: true,
+    topic: "host_request",
+  },
+  {
+    action: "message",
+    description: "Someone sends a message in a host request",
+    userEditable: true,
+    push: true,
+    digest: false,
+    email: false,
+    topic: "host_request",
+  },
+  {
+    action: "missed_messages",
+    description: "You miss messages in a host request",
+    digest: false,
+    email: true,
+    push: false,
+    topic: "host_request",
+    userEditable: true,
+  },
+];
+
+export default {
+  title: "Features/Auth/Notifications/NotificationSettingsListItem",
+  component: NotificationSettingsListItem,
+  decorators: [
+    (Story) => (
+      <I18nextProvider i18n={i18n}>
+        <ThemeProvider theme={theme}>
+          <Story />
+        </ThemeProvider>
+      </I18nextProvider>
+    ),
+  ],
+} as Meta;
+
+const Template: Story<NotificationSettingsListItemProps> = (args) => (
+  <NotificationSettingsListItem {...args} />
+);
+
+export const Default = Template.bind({});
+Default.args = {
+  items: mockItems,
+  type: "host_request",
+};
+
+export const WithAllUserEditableFalse = Template.bind({});
+WithAllUserEditableFalse.args = {
+  items: mockItems.map((item) => ({ ...item, userEditable: false })),
+  type: "host_request",
+};
diff --git a/app/web/features/auth/notifications/NotificationSettingsListItem.tsx b/app/web/features/auth/notifications/NotificationSettingsListItem.tsx
new file mode 100644
index 0000000000..329ba95aa0
--- /dev/null
+++ b/app/web/features/auth/notifications/NotificationSettingsListItem.tsx
@@ -0,0 +1,103 @@
+import {
+  Collapse,
+  ListItem,
+  ListItemIcon,
+  ListItemText,
+  Typography,
+} from "@material-ui/core";
+import {
+  AccountSecurityIcon,
+  AccountSettingsIcon,
+  ChatBubbleIcon,
+  CouchFilledIcon,
+  EventIcon,
+  ExpandLessIcon,
+  ExpandMoreIcon,
+  PenIcon,
+  SinglePersonIcon,
+} from "components/Icons";
+import { AUTH } from "i18n/namespaces";
+import { useTranslation } from "next-i18next";
+import { useState } from "react";
+import makeStyles from "utils/makeStyles";
+
+import { GroupAction, NotificationType } from "./EditNotificationSettingsPage";
+import NotificationSettingsSubListItem from "./NotificationSettingsSubListItem";
+
+export interface NotificationSettingsListItemProps {
+  items: GroupAction[];
+  type: NotificationType;
+}
+
+const useStyles = makeStyles((theme) => ({
+  descriptionText: {
+    fontSize: theme.spacing(1.8),
+    color: theme.palette.text.secondary,
+  },
+  listItem: {
+    "&:hover": {
+      backgroundColor: "transparent",
+    },
+    "&:not(:first-child)": {
+      borderTop: `1px solid ${theme.palette.divider}`,
+    },
+  },
+}));
+
+const mapTypeToIcon: { [key: string]: JSX.Element } = {
+  account_security: <AccountSecurityIcon fontSize="large" color="action" />,
+  account_settings: <AccountSettingsIcon fontSize="large" color="action" />,
+  chat: <ChatBubbleIcon fontSize="large" color="action" />,
+  event: <EventIcon fontSize="large" color="action" />,
+  reference: <PenIcon fontSize="large" color="action" />,
+  friend_request: <SinglePersonIcon fontSize="large" color="action" />,
+  host_request: <CouchFilledIcon fontSize="large" color="action" />,
+};
+
+export default function NotificationSettingsListItem({
+  items,
+  type,
+}: NotificationSettingsListItemProps) {
+  const classes = useStyles();
+  const notificationType =
+    type as `auth:notification_settings.edit_preferences.list_items.${NotificationType}`;
+
+  const { t } = useTranslation([AUTH], {
+    keyPrefix: "notification_settings.edit_preferences.list_items",
+  });
+  const [isCollapseOpen, setIsCollapseOpen] = useState<boolean>(false);
+
+  const handleCollapseClick = () => {
+    setIsCollapseOpen(!isCollapseOpen);
+  };
+
+  const renderItems = () =>
+    items
+      .filter((item) => item.userEditable)
+      .map((item) => (
+        <NotificationSettingsSubListItem
+          key={`${item.topic}:${item.action}`}
+          topic={item.topic}
+          action={item.action}
+          push={item.push}
+          email={item.email}
+        />
+      ));
+
+  return (
+    <>
+      <ListItem
+        button
+        className={classes.listItem}
+        onClick={handleCollapseClick}
+      >
+        <ListItemIcon>{mapTypeToIcon[type]}</ListItemIcon>
+        <ListItemText>
+          <Typography variant="h3">{t(notificationType)}</Typography>
+        </ListItemText>
+        {isCollapseOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
+      </ListItem>
+      <Collapse in={isCollapseOpen}>{renderItems()}</Collapse>
+    </>
+  );
+}
diff --git a/app/web/features/auth/notifications/NotificationSettingsSubListItem.stories.tsx b/app/web/features/auth/notifications/NotificationSettingsSubListItem.stories.tsx
new file mode 100644
index 0000000000..33d9d1cc0f
--- /dev/null
+++ b/app/web/features/auth/notifications/NotificationSettingsSubListItem.stories.tsx
@@ -0,0 +1,52 @@
+import { ThemeProvider } from "@material-ui/core/styles";
+import { Meta, Story } from "@storybook/react";
+import React from "react";
+import { I18nextProvider } from "react-i18next";
+import i18n from "test/i18n";
+import { theme } from "theme";
+
+import NotificationSettingsSubListItem, {
+  NotificationSettingsSubListItemProps,
+} from "./NotificationSettingsSubListItem";
+
+export default {
+  title: "Features/Auth/Notifications/NotificationSettingsSubListItem",
+  component: NotificationSettingsSubListItem,
+  decorators: [
+    (Story) => (
+      <I18nextProvider i18n={i18n}>
+        <ThemeProvider theme={theme}>
+          <Story />
+        </ThemeProvider>
+      </I18nextProvider>
+    ),
+  ],
+} as Meta;
+
+const Template: Story<NotificationSettingsSubListItemProps> = (args) => (
+  <NotificationSettingsSubListItem {...args} />
+);
+
+export const Default = Template.bind({});
+Default.args = {
+  topic: "host_request",
+  action: "create",
+  email: true,
+  push: false,
+};
+
+export const EmailAndPushEnabled = Template.bind({});
+EmailAndPushEnabled.args = {
+  topic: "host_request",
+  action: "create",
+  email: true,
+  push: true,
+};
+
+export const EmailDisabledPushEnabled = Template.bind({});
+EmailDisabledPushEnabled.args = {
+  topic: "host_request",
+  action: "create",
+  email: false,
+  push: true,
+};
diff --git a/app/web/features/auth/notifications/NotificationSettingsSubListItem.tsx b/app/web/features/auth/notifications/NotificationSettingsSubListItem.tsx
new file mode 100644
index 0000000000..00f69c1cbf
--- /dev/null
+++ b/app/web/features/auth/notifications/NotificationSettingsSubListItem.tsx
@@ -0,0 +1,156 @@
+import {
+  List,
+  ListItem,
+  ListItemIcon,
+  ListItemText,
+  Typography,
+} from "@material-ui/core";
+import { MailOutline } from "@material-ui/icons";
+import Alert from "components/Alert";
+import CustomColorSwitch from "components/CustomColorSwitch";
+import { NotificationNewIcon } from "components/Icons";
+import { AUTH, GLOBAL } from "i18n/namespaces";
+import { useTranslation } from "next-i18next";
+import { useState } from "react";
+import { NotificationPreferenceData } from "service/notifications";
+import { theme } from "theme";
+import makeStyles from "utils/makeStyles";
+
+import useUpdateNotificationSettings from "./useUpdateNotificationSettings";
+
+export interface NotificationSettingsSubListItemProps {
+  topic: string;
+  action: string;
+  email: boolean;
+  push: boolean;
+}
+
+const useStyles = makeStyles((theme) => ({
+  descriptionText: {
+    fontSize: theme.spacing(1.8),
+    color: theme.palette.text.secondary,
+  },
+  nested: {
+    display: "flex",
+    paddingLeft: theme.spacing(4),
+    width: "100%",
+    "&:hover": {
+      backgroundColor: "transparent",
+    },
+    "&:not(:first-child)": {
+      borderTop: `1px solid ${theme.palette.divider}`,
+    },
+  },
+}));
+
+export default function NotificationSettingsSubListItem({
+  topic,
+  action,
+  email,
+  push,
+}: NotificationSettingsSubListItemProps) {
+  const classes = useStyles();
+  const { t } = useTranslation([AUTH, GLOBAL], {
+    keyPrefix: "notification_settings.edit_preferences.item_descriptions",
+  });
+
+  const { updateNotificationSettings, status } =
+    useUpdateNotificationSettings();
+  const [mutationError, setMutationError] = useState<string | null>(null);
+  const [isPushLoading, setIsPushLoading] = useState(false);
+  const [isEmailLoading, setIsEmailLoading] = useState(false);
+
+  const handlePushSwitchClick = () => {
+    setIsPushLoading(true);
+    const updatedItem: NotificationPreferenceData = {
+      topic,
+      action,
+      deliveryMethod: "push",
+      enabled: !push,
+    };
+    updateNotificationSettings(
+      {
+        preferenceData: updatedItem,
+        setMutationError,
+      },
+      {
+        onError: () => {
+          window.scroll({ top: 0, behavior: "smooth" });
+          setIsPushLoading(false);
+        },
+        onSettled: () => {
+          setIsPushLoading(false);
+        },
+      }
+    );
+  };
+
+  const handleEmailSwitchClick = () => {
+    const updatedItem: NotificationPreferenceData = {
+      topic,
+      action,
+      deliveryMethod: "email",
+      enabled: !email,
+    };
+    setIsEmailLoading(true);
+    updateNotificationSettings(
+      {
+        preferenceData: updatedItem,
+        setMutationError,
+      },
+      {
+        onError: () => {
+          window.scroll({ top: 0, behavior: "smooth" });
+          setIsEmailLoading(false);
+        },
+        onSettled: () => {
+          setIsEmailLoading(false);
+        },
+      }
+    );
+  };
+
+  return (
+    <>
+      {mutationError && (
+        <Alert severity="error">
+          {mutationError || t("global:error.unknown")}
+        </Alert>
+      )}
+      <Typography className={classes.descriptionText}>
+        {t(
+          //@ts-ignore - I spent hours on this type with no luck
+          `${topic}.${action}`
+        )}
+      </Typography>
+      <List component="div" disablePadding>
+        <ListItem button className={classes.nested}>
+          <ListItemIcon>
+            <NotificationNewIcon fontSize="medium" />
+          </ListItemIcon>
+          <ListItemText primary="Push" />
+          <CustomColorSwitch
+            color={theme.palette.primary.main}
+            checked={push}
+            isLoading={isPushLoading}
+            onClick={handlePushSwitchClick}
+            status={status}
+          />
+        </ListItem>
+        <ListItem button className={classes.nested}>
+          <ListItemIcon>
+            <MailOutline fontSize="medium" />
+          </ListItemIcon>
+          <ListItemText primary="Email" />
+          <CustomColorSwitch
+            color={theme.palette.primary.main}
+            checked={email}
+            isLoading={isEmailLoading}
+            onClick={handleEmailSwitchClick}
+            status={status}
+          />
+        </ListItem>
+      </List>
+    </>
+  );
+}
diff --git a/app/web/features/auth/notifications/useNotificationSettings.ts b/app/web/features/auth/notifications/useNotificationSettings.ts
new file mode 100644
index 0000000000..e431275ece
--- /dev/null
+++ b/app/web/features/auth/notifications/useNotificationSettings.ts
@@ -0,0 +1,17 @@
+import { notificationSettingsQueryKey } from "features/queryKeys";
+import { RpcError } from "grpc-web";
+import { GetNotificationSettingsRes } from "proto/notifications_pb";
+import { useQuery } from "react-query";
+import { service } from "service";
+
+export default function useNotificationSettings() {
+  const notificationSettingsQuery = useQuery<
+    GetNotificationSettingsRes.AsObject,
+    RpcError
+  >(
+    notificationSettingsQueryKey,
+    service.notifications.getNotificationSettings
+  );
+
+  return notificationSettingsQuery;
+}
diff --git a/app/web/features/auth/notifications/useUpdateNotificationSettings.ts b/app/web/features/auth/notifications/useUpdateNotificationSettings.ts
new file mode 100644
index 0000000000..5fbd0f2bec
--- /dev/null
+++ b/app/web/features/auth/notifications/useUpdateNotificationSettings.ts
@@ -0,0 +1,49 @@
+import { notificationSettingsQueryKey } from "features/queryKeys";
+import { RpcError } from "grpc-web";
+import { GetNotificationSettingsRes } from "proto/notifications_pb";
+import { useMutation, useQueryClient } from "react-query";
+import { service } from "service";
+import { NotificationPreferenceData } from "service/notifications";
+import { SetMutationError } from "utils/setMutationError";
+
+export default function useUpdateNotificationSettings() {
+  const queryClient = useQueryClient();
+  const {
+    mutate: updateNotificationSettings,
+    reset,
+    isLoading,
+    isError,
+    isSuccess,
+    status,
+  } = useMutation<
+    GetNotificationSettingsRes.AsObject,
+    RpcError,
+    {
+      preferenceData: NotificationPreferenceData;
+      setMutationError: SetMutationError;
+    }
+  >(
+    ({ preferenceData }: { preferenceData: NotificationPreferenceData }) =>
+      service.notifications.setNotificationSettingsPreference(preferenceData),
+    {
+      onError: (error, { setMutationError }) => {
+        setMutationError(error.message);
+      },
+      onMutate: ({ setMutationError }) => {
+        setMutationError(null);
+      },
+      onSuccess: () => {
+        queryClient.invalidateQueries(notificationSettingsQueryKey);
+      },
+    }
+  );
+
+  return {
+    updateNotificationSettings,
+    reset,
+    isLoading,
+    isError,
+    isSuccess,
+    status,
+  };
+}
diff --git a/app/web/features/queryKeys.ts b/app/web/features/queryKeys.ts
index f22ce4df11..e4b6fe7825 100644
--- a/app/web/features/queryKeys.ts
+++ b/app/web/features/queryKeys.ts
@@ -9,6 +9,7 @@ export const accountInfoQueryKey = "accountInfo";
 export const doNotEmailQueryKey = "doNotEmail";
 export const tosQueryKey = "tos";
 export const communityGuidelinesQueryKey = "communityGuidelines";
+export const notificationSettingsQueryKey = "notificationSettings";
 
 export function userKey(userId?: number) {
   return userId === undefined ? "user" : ["user", userId];
diff --git a/app/web/package.json b/app/web/package.json
index ce3dc03aaf..8ef9e5b609 100644
--- a/app/web/package.json
+++ b/app/web/package.json
@@ -14,6 +14,7 @@
     "format:check": "prettier --check '**/(*.ts|.tsx)'",
     "jest": "jest --modulePaths=.",
     "lint": "next lint",
+    "lint:fix": "next lint --fix .",
     "serve": "next start",
     "storybook": "start-storybook -p 6006 -s public",
     "test-ci": "tsc && cross-env NEXT_PUBLIC_API_BASE_URL=http://localhost:8888 CI=true TZ=UTC jest --runInBand --coverageReporters=\"cobertura\" --coverageReporters=\"lcov\" --reporters=\"default\" --reporters=\"jest-junit\" --coverage",
diff --git a/app/web/pages/account-settings.tsx b/app/web/pages/account-settings/index.tsx
similarity index 100%
rename from app/web/pages/account-settings.tsx
rename to app/web/pages/account-settings/index.tsx
diff --git a/app/web/pages/account-settings/notifications.tsx b/app/web/pages/account-settings/notifications.tsx
new file mode 100644
index 0000000000..fbeb358d02
--- /dev/null
+++ b/app/web/pages/account-settings/notifications.tsx
@@ -0,0 +1,22 @@
+import { appGetLayout } from "components/AppRoute";
+import EditNotificationSettingsPageComponent from "features/auth/notifications/EditNotificationSettingsPage";
+import { AUTH, GLOBAL } from "i18n/namespaces";
+import { GetStaticProps } from "next";
+import nextI18nextConfig from "next-i18next.config";
+import { serverSideTranslations } from "next-i18next/serverSideTranslations";
+
+export const getStaticProps: GetStaticProps = async ({ locale }) => ({
+  props: {
+    ...(await serverSideTranslations(
+      locale ?? "en",
+      [GLOBAL, AUTH],
+      nextI18nextConfig
+    )),
+  },
+});
+
+export default function EditNotificationSettingsPage() {
+  return <EditNotificationSettingsPageComponent />;
+}
+
+EditNotificationSettingsPage.getLayout = appGetLayout();
diff --git a/app/web/routes.ts b/app/web/routes.ts
index e5ec7c1a11..d5c25db972 100644
--- a/app/web/routes.ts
+++ b/app/web/routes.ts
@@ -26,12 +26,14 @@ export const contactRoute = "/contact";
 
 export const loginRoute = "/login";
 export const resetPasswordRoute = "/password-reset";
-export const settingsRoute = "/account-settings";
 export const featurePreviewRoute = "/preview";
 export const confirmChangeEmailRoute = "/confirm-email";
 
 export const signupRoute = "/signup";
 
+export const settingsRoute = "/account-settings";
+export const notificationSettingsRoute = `${settingsRoute}/notifications`;
+
 // profile
 export const userTabs = [
   "about",
diff --git a/app/web/service/notifications.ts b/app/web/service/notifications.ts
index 7655eb041b..90d030b54b 100644
--- a/app/web/service/notifications.ts
+++ b/app/web/service/notifications.ts
@@ -1,10 +1,18 @@
 import {
   GetNotificationSettingsReq,
   SetNotificationSettingsReq,
+  SingleNotificationPreference,
 } from "proto/notifications_pb";
 
 import client from "./client";
 
+export interface NotificationPreferenceData {
+  topic: string;
+  action: string;
+  deliveryMethod: "push" | "email";
+  enabled: boolean;
+}
+
 export async function getNotificationSettings() {
   const res = await client.notifications.getNotificationSettings(
     new GetNotificationSettingsReq()
@@ -18,3 +26,19 @@ export async function setNotificationSettings(enableDoNotEmail: boolean) {
   const res = await client.notifications.setNotificationSettings(req);
   return res.toObject();
 }
+
+export async function setNotificationSettingsPreference(
+  preferenceData: NotificationPreferenceData
+) {
+  const req = new SetNotificationSettingsReq();
+  const preference = new SingleNotificationPreference();
+  preference.setTopic(preferenceData.topic);
+  preference.setAction(preferenceData.action);
+  preference.setDeliveryMethod(preferenceData.deliveryMethod);
+  preference.setEnabled(preferenceData.enabled);
+  req.setPreferencesList([preference]);
+
+  const res = await client.notifications.setNotificationSettings(req);
+
+  return res.toObject();
+}
diff --git a/app/web/stories/serviceMocks.ts b/app/web/stories/serviceMocks.ts
index d66bd1497d..a297e5c192 100644
--- a/app/web/stories/serviceMocks.ts
+++ b/app/web/stories/serviceMocks.ts
@@ -2,6 +2,7 @@ import { service as originalService } from "service/index";
 import comments from "test/fixtures/comments.json";
 import groupChat from "test/fixtures/groupChat.json";
 import messages from "test/fixtures/messages.json";
+import notifications from "test/fixtures/notifications.json";
 import users from "test/fixtures/users.json";
 
 const [user1, user2, user3, user4] = users;
@@ -39,6 +40,9 @@ export const mockedService = {
         noMore: true,
       }),
   },
+  notifications: {
+    getNotificationSettings: () => Promise.resolve(notifications),
+  },
   resources: {
     getCommunityGuidelines: () =>
       Promise.resolve({
diff --git a/docs/urls.md b/docs/urls.md
index cafc913d40..e4f4ad8ba1 100644
--- a/docs/urls.md
+++ b/docs/urls.md
@@ -74,3 +74,6 @@ URL:  https://couchers.org/password-reset
 
 Title: Complete password reset / set new password page
 URL: https://couchers.org/complete-password-reset?token=abc123
+
+Title: Edit notification settings
+URL:  https://couchers.org/account-settings/notifications


Test patch:
diff --git a/app/web/features/auth/notifications/EditNotificationSettingsPage.test.tsx b/app/web/features/auth/notifications/EditNotificationSettingsPage.test.tsx
new file mode 100644
index 0000000000..f4008952b9
--- /dev/null
+++ b/app/web/features/auth/notifications/EditNotificationSettingsPage.test.tsx
@@ -0,0 +1,241 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { useTranslation } from "next-i18next";
+import { useMutation, useQueryClient } from "react-query";
+import { NotificationPreferenceData } from "service/notifications";
+
+import EditNotificationSettingsPage from "./EditNotificationSettingsPage";
+import NotificationSettingsSubListItem from "./NotificationSettingsSubListItem";
+import useNotificationSettings from "./useNotificationSettings";
+import useUpdateNotificationSettings from "./useUpdateNotificationSettings";
+
+jest.mock("react-query", () => ({
+  useMutation: jest.fn(),
+  useQueryClient: jest.fn(),
+}));
+
+jest.mock("./useUpdateNotificationSettings");
+
+// Mock useNotificationSettings hook
+jest.mock("./useNotificationSettings", () => ({
+  __esModule: true,
+  default: jest.fn(),
+}));
+
+// Mock useTranslation hook
+jest.mock("next-i18next", () => ({
+  useTranslation: jest.fn(() => ({
+    t: (key: string) => key,
+  })),
+}));
+
+describe("EditNotificationSettingsPage", () => {
+  const mockMutate = jest.fn();
+
+  const mockNotificationData = {
+    groupsList: [
+      {
+        heading: "Account Security",
+        topicsList: [
+          {
+            topic: "password",
+            itemsList: [
+              {
+                action: "change",
+                description: "Your password is changed",
+                email: true,
+                push: false,
+                userEditable: true,
+              },
+            ],
+          },
+        ],
+      },
+      {
+        heading: "Host requests",
+        topicsList: [
+          {
+            topic: "host_request",
+            itemsList: [
+              {
+                action: "create",
+                description: "Someone sends you a host request",
+                email: false,
+                push: true,
+                userEditable: true,
+              },
+            ],
+          },
+        ],
+      },
+      {
+        heading: "Friend requests",
+        topicsList: [
+          {
+            topic: "friend_request",
+            itemsList: [
+              {
+                action: "create",
+                description: "Test description not user editable",
+                email: true,
+                push: false,
+                userEditable: false,
+              },
+            ],
+          },
+        ],
+      },
+    ],
+  };
+
+  beforeEach(() => {
+    // Ensure useTranslation returns a function that returns the expected object with a 't' function.
+    (useTranslation as jest.Mock).mockReturnValue({
+      t: (key: string) => key,
+    });
+
+    const mockQueryClient = {
+      invalidateQueries: jest.fn(),
+    };
+
+    (useQueryClient as jest.Mock).mockReturnValue(mockQueryClient);
+    (useMutation as jest.Mock).mockReturnValue({
+      mutate: mockMutate,
+      reset: jest.fn(),
+      isLoading: false,
+      isError: false,
+      status: "idle",
+    });
+
+    (useUpdateNotificationSettings as jest.Mock).mockReturnValue({
+      updateNotificationSettings: mockMutate,
+      reset: jest.fn(),
+      isLoading: false,
+      isError: false,
+      status: "idle",
+    });
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+    jest.resetAllMocks();
+  });
+
+  it("Renders loading state correctly", () => {
+    (useNotificationSettings as jest.Mock).mockReturnValue({
+      data: null,
+      isLoading: true,
+      isError: false,
+    });
+
+    render(<EditNotificationSettingsPage />);
+
+    expect(screen.getByRole("progressbar")).toBeInTheDocument();
+  });
+
+  it("Renders error state correctly", () => {
+    (useNotificationSettings as jest.Mock).mockReturnValue({
+      data: null,
+      isLoading: false,
+      isError: true,
+    });
+
+    render(<EditNotificationSettingsPage />);
+
+    expect(screen.getByText("error_loading")).toBeInTheDocument();
+  });
+
+  it("Renders notification settings list correctly when data is loaded", () => {
+    (useNotificationSettings as jest.Mock).mockReturnValue({
+      data: mockNotificationData,
+      isLoading: false,
+      isError: false,
+    });
+
+    render(<EditNotificationSettingsPage />);
+
+    // Check if the title, description, and list heading are rendered
+    expect(screen.getByText("title")).toBeInTheDocument();
+    expect(screen.getByText("description")).toBeInTheDocument();
+    expect(screen.getByText("list_heading")).toBeInTheDocument();
+
+    // Check if NotificationSettingsListItems are rendered
+    expect(screen.getByText("account_security")).toBeInTheDocument();
+    expect(screen.getByText("password.change")).toBeInTheDocument();
+
+    expect(screen.getByText("host_request")).toBeInTheDocument();
+    expect(screen.getByText("host_request.create")).toBeInTheDocument();
+  });
+
+  it("Handles no data gracefully", () => {
+    (useNotificationSettings as jest.Mock).mockReturnValue({
+      data: { groupsList: [] },
+      isLoading: false,
+      isError: false,
+    });
+
+    render(<EditNotificationSettingsPage />);
+
+    expect(screen.getByText("title")).toBeInTheDocument();
+    expect(screen.queryByText("account_security")).not.toBeInTheDocument();
+    expect(screen.queryByText("password.change")).not.toBeInTheDocument();
+  });
+
+  it("Should call updateNotificationSettings on switch click with opposite value", async () => {
+    const preferenceData: NotificationPreferenceData = {
+      topic: "host_request",
+      action: "host_request",
+      deliveryMethod: "push",
+      enabled: true,
+    };
+
+    const { container } = render(
+      <NotificationSettingsSubListItem
+        topic="host_request"
+        action="host_request"
+        email={false}
+        push={false}
+      />
+    );
+
+    const emailSwitch = container.getElementsByClassName("MuiSwitch-input")[0];
+
+    expect(emailSwitch).not.toBeChecked();
+
+    fireEvent.click(emailSwitch);
+
+    await waitFor(() =>
+      expect(mockMutate).toHaveBeenCalledWith(
+        {
+          preferenceData: { ...preferenceData, enabled: true },
+          setMutationError: expect.any(Function),
+        },
+        expect.any(Object)
+      )
+    );
+  });
+
+  it('Should not render list items with "userEditable" set to false', () => {
+    (useNotificationSettings as jest.Mock).mockReturnValue({
+      data: mockNotificationData,
+      isLoading: false,
+      isError: false,
+    });
+
+    render(<EditNotificationSettingsPage />);
+
+    // Check if the title, description, and list heading are rendered
+    expect(screen.getByText("title")).toBeInTheDocument();
+    expect(screen.getByText("description")).toBeInTheDocument();
+    expect(screen.getByText("list_heading")).toBeInTheDocument();
+
+    // Check if NotificationSettingsListItems are rendered
+    expect(screen.getByText("account_security")).toBeInTheDocument();
+    expect(screen.getByText("password.change")).toBeInTheDocument();
+
+    expect(screen.getByText("host_request")).toBeInTheDocument();
+    expect(screen.getByText("host_request.create")).toBeInTheDocument();
+
+    expect(screen.queryByText("friend_request")).not.toBeInTheDocument();
+    expect(screen.queryByText("friend_request.create")).not.toBeInTheDocument();
+  });
+});
diff --git a/app/web/features/auth/notifications/useNotificationSettings.test.ts b/app/web/features/auth/notifications/useNotificationSettings.test.ts
new file mode 100644
index 0000000000..bee180a0e9
--- /dev/null
+++ b/app/web/features/auth/notifications/useNotificationSettings.test.ts
@@ -0,0 +1,83 @@
+import { renderHook } from "@testing-library/react-hooks";
+import { RpcError, StatusCode } from "grpc-web";
+import { GetNotificationSettingsRes } from "proto/notifications_pb";
+import { QueryClient } from "react-query";
+import { service } from "service";
+import wrapper from "test/hookWrapper";
+
+import useNotificationSettings from "./useNotificationSettings";
+
+const queryClient = new QueryClient();
+
+describe("useNotificationSettings", () => {
+  afterEach(() => {
+    jest.clearAllMocks();
+    queryClient.clear();
+  });
+
+  it("should return data when the request is successful", async () => {
+    const mockData: GetNotificationSettingsRes.AsObject = {
+      doNotEmailEnabled: false,
+      emailDigestEnabled: false,
+      groupsList: [
+        {
+          heading: "Account Security",
+          topicsList: [
+            {
+              name: "Password change",
+              topic: "password",
+              itemsList: [
+                {
+                  action: "change",
+                  description: "Your password is changed",
+                  digest: false,
+                  email: true,
+                  push: true,
+                  userEditable: false,
+                },
+              ],
+            },
+          ],
+        },
+      ],
+    };
+    (
+      service.notifications.getNotificationSettings as jest.Mock
+    ).mockResolvedValue(mockData);
+
+    const { result, waitFor } = renderHook(() => useNotificationSettings(), {
+      wrapper,
+    });
+
+    await waitFor(() => result.current.isSuccess);
+
+    expect(service.notifications.getNotificationSettings).toHaveBeenCalledTimes(
+      1
+    );
+    expect(result.current.data).toEqual(mockData);
+  });
+
+  it("should return an error when the request fails", async () => {
+    const mockError = new RpcError(StatusCode.UNKNOWN, "Error message", {});
+    (
+      service.notifications.getNotificationSettings as jest.Mock
+    ).mockRejectedValue(mockError);
+
+    const { result, waitFor } = renderHook(() => useNotificationSettings(), {
+      wrapper,
+    });
+
+    await waitFor(() => result.current.isError);
+
+    expect(service.notifications.getNotificationSettings).toHaveBeenCalledTimes(
+      1
+    );
+    expect(result.current.error).toEqual(mockError);
+  });
+
+  it("should be in loading state initially", () => {
+    const { result } = renderHook(() => useNotificationSettings(), { wrapper });
+
+    expect(result.current.isLoading).toBe(true);
+  });
+});
diff --git a/app/web/features/auth/notifications/useUpdateNotificationSettings.test.tsx b/app/web/features/auth/notifications/useUpdateNotificationSettings.test.tsx
new file mode 100644
index 0000000000..bc44560201
--- /dev/null
+++ b/app/web/features/auth/notifications/useUpdateNotificationSettings.test.tsx
@@ -0,0 +1,100 @@
+import { act, renderHook } from "@testing-library/react-hooks";
+import { notificationSettingsQueryKey } from "features/queryKeys";
+import { RpcError, StatusCode } from "grpc-web";
+import { QueryClient, QueryClientProvider } from "react-query";
+import { service } from "service";
+
+import useUpdateNotificationSettings from "./useUpdateNotificationSettings";
+
+jest.mock("service");
+
+const queryClient = new QueryClient();
+
+describe("useUpdateNotificationSettings", () => {
+  afterEach(() => {
+    jest.clearAllMocks();
+    queryClient.clear();
+  });
+
+  const wrapper = ({ children }: { children: React.ReactNode }) => (
+    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+  );
+
+  it("Should have the correct initial state", () => {
+    const { result } = renderHook(() => useUpdateNotificationSettings(), {
+      wrapper,
+    });
+
+    expect(result.current.isLoading).toBe(false);
+    expect(result.current.isError).toBe(false);
+    expect(result.current.isSuccess).toBe(false);
+    expect(result.current.status).toBe("idle");
+  });
+
+  it("Should call the service and invalidate the query on success", async () => {
+    const mockData = {
+      topic: "password",
+      action: "change",
+      deliveryMethod: "email" as const,
+      enabled: true,
+    };
+
+    (
+      service.notifications.setNotificationSettingsPreference as jest.Mock
+    ).mockResolvedValue(
+      {} // Mocked successful response
+    );
+
+    jest.spyOn(queryClient, "invalidateQueries");
+
+    const { result } = renderHook(() => useUpdateNotificationSettings(), {
+      wrapper,
+    });
+
+    await act(async () => {
+      result.current.updateNotificationSettings({
+        preferenceData: mockData,
+        setMutationError: jest.fn(),
+      });
+    });
+
+    expect(
+      service.notifications.setNotificationSettingsPreference
+    ).toHaveBeenCalledWith(mockData);
+    expect(queryClient.invalidateQueries).toHaveBeenCalledWith(
+      notificationSettingsQueryKey
+    );
+  });
+
+  it("Should handle errors correctly", async () => {
+    const mockError = new RpcError(
+      StatusCode.CANCELLED,
+      "Test error message",
+      {}
+    );
+    const setMutationError = jest.fn();
+
+    (
+      service.notifications.setNotificationSettingsPreference as jest.Mock
+    ).mockRejectedValue(mockError);
+
+    const { result } = renderHook(() => useUpdateNotificationSettings(), {
+      wrapper,
+    });
+
+    await act(async () => {
+      result.current.updateNotificationSettings({
+        preferenceData: {
+          topic: "password",
+          action: "change",
+          deliveryMethod: "email",
+          enabled: true,
+        },
+        setMutationError,
+      });
+    });
+
+    expect(setMutationError).toHaveBeenCalledWith("Test error message");
+    expect(result.current.isError).toBe(true);
+  });
+});
diff --git a/app/web/test/fixtures/notifications.json b/app/web/test/fixtures/notifications.json
new file mode 100644
index 0000000000..ebd041541d
--- /dev/null
+++ b/app/web/test/fixtures/notifications.json
@@ -0,0 +1,80 @@
+{
+  "doNotEmailEnabled": false,
+  "emailDigestEnabled": false,
+  "groupsList": [
+    {
+      "heading": "Core Features",
+      "topicsList": [
+        {
+          "topic": "host_request",
+          "name": "Host Requests",
+          "itemsList": [
+            {
+              "action": "create",
+              "description": "Someone sends you a host request",
+              "userEditable": true,
+              "push": false,
+              "digest": false,
+              "email": true,
+              "topic": "host_request"
+            },
+            {
+              "action": "accept",
+              "description": "Someone accepts your host request",
+              "userEditable": true,
+              "push": true,
+              "email": true,
+              "digest": false,
+              "topic": "host_request"
+            },
+            {
+              "action": "confirm",
+              "description": "Someone confirms their host request",
+              "userEditable": true,
+              "push": true,
+              "email": true,
+              "digest": false,
+              "topic": "host_request"
+            },
+            {
+              "action": "reject",
+              "description": "Someone declines your host request",
+              "userEditable": true,
+              "push": true,
+              "email": true,
+              "digest": false,
+              "topic": "host_request"
+            },
+            {
+              "action": "cancel",
+              "description": "Someone cancels their host request",
+              "userEditable": true,
+              "push": true,
+              "digest": false,
+              "email": true,
+              "topic": "host_request"
+            },
+            {
+              "action": "message",
+              "description": "Someone sends a message in a host request",
+              "userEditable": true,
+              "push": true,
+              "digest": false,
+              "email": false,
+              "topic": "host_request"
+            },
+            {
+              "action": "missed_messages",
+              "description": "You miss messages in a host request",
+              "digest": false,
+              "email": true,
+              "push": false,
+              "topic": "host_request",
+              "userEditable": true
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}