diff --git a/cypress/e2e/account/settings/preferences.cy.ts b/cypress/e2e/account/settings/preferences.cy.ts index f391d1c17..a470a302b 100644 --- a/cypress/e2e/account/settings/preferences.cy.ts +++ b/cypress/e2e/account/settings/preferences.cy.ts @@ -1,4 +1,4 @@ -import { EmailFrequency } from '@graasp/sdk'; +import { EmailFrequency, HttpMethod } from '@graasp/sdk'; import { LANGS } from '../../../../src/config/langs'; import { ACCOUNT_SETTINGS_PATH } from '../../../../src/config/paths'; @@ -10,10 +10,14 @@ import { PREFERENCES_EMAIL_FREQUENCY_ID, PREFERENCES_LANGUAGE_DISPLAY_ID, PREFERENCES_LANGUAGE_SWITCH_ID, + PREFERENCES_MARKETING_SUBSCRIPTION_DISPLAY_ID, PREFERENCES_SAVE_BUTTON_ID, } from '../../../../src/config/selectors'; import { CURRENT_MEMBER, MEMBERS } from '../../../fixtures/members'; +const MARKETING_EMAIL_SUBSCRIPTION_SWITCH_SELECTOR = + '[name="I want to receive Graasp\'s updates and communication"]'; + describe('Display preferences', () => { describe('Language', () => { for (const [lang, expectedLabel] of Object.entries(LANGS)) { @@ -37,7 +41,7 @@ describe('Display preferences', () => { } }); - describe('Email frequency', () => { + describe('Notification frequency', () => { for (const { emailFreq, expectedText } of [ { emailFreq: EmailFrequency.Always, @@ -58,7 +62,6 @@ describe('Display preferences', () => { currentMember, }); cy.visit(ACCOUNT_SETTINGS_PATH); - cy.wait('@getCurrentMember'); cy.get(`#${PREFERENCES_EMAIL_FREQUENCY_ID}`).should( 'contain', expectedText, @@ -67,6 +70,72 @@ describe('Display preferences', () => { } }); + describe('Marketing emails subscription', () => { + it('Enabled', () => { + cy.setUpApi({ + currentSettings: { + marketingEmailsSubscribedAt: new Date().toISOString(), + }, + }); + cy.intercept( + { + method: HttpMethod.Post, + pathname: /\/api\/members\/current\/marketing\/unsubscribe$/, + }, + ({ reply }) => { + reply(); + }, + ).as('unsubscribe'); + + cy.visit(ACCOUNT_SETTINGS_PATH); + cy.get(`#${PREFERENCES_MARKETING_SUBSCRIPTION_DISPLAY_ID}`).should( + 'contain', + 'Enabled', + ); + + // edit setting + cy.get(`#${PREFERENCES_EDIT_BUTTON_ID}`).click(); + cy.get(MARKETING_EMAIL_SUBSCRIPTION_SWITCH_SELECTOR) + .should('be.checked') + .click(); + + cy.get(`#${PREFERENCES_SAVE_BUTTON_ID}`).click(); + cy.wait('@unsubscribe'); + }); + + it('Disabled', () => { + cy.setUpApi({ + currentSettings: { + marketingEmailsSubscribedAt: null, + }, + }); + cy.intercept( + { + method: HttpMethod.Post, + pathname: /\/api\/members\/current\/marketing\/subscribe$/, + }, + ({ reply }) => { + reply(); + }, + ).as('subscribe'); + + cy.visit(ACCOUNT_SETTINGS_PATH); + cy.get(`#${PREFERENCES_MARKETING_SUBSCRIPTION_DISPLAY_ID}`).should( + 'contain', + 'Disabled', + ); + + // edit setting + cy.get(`#${PREFERENCES_EDIT_BUTTON_ID}`).click(); + cy.get(MARKETING_EMAIL_SUBSCRIPTION_SWITCH_SELECTOR) + .should('not.be.checked') + .click(); + + cy.get(`#${PREFERENCES_SAVE_BUTTON_ID}`).click(); + cy.wait('@subscribe'); + }); + }); + describe('Enable Analytics', () => { for (const { enableSaveActions, expectedLabel } of [ { enableSaveActions: true, expectedLabel: 'Enabled' }, diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 8fccf8b77..792e9c15d 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -100,6 +100,7 @@ import { mockGetChildren, mockGetCurrentMember, mockGetCurrentMemberAvatar, + mockGetCurrentSettings, mockGetDescendants, mockGetItem, mockGetItemBookmarks, @@ -278,6 +279,7 @@ Cypress.Commands.add( 'setUpApi', ({ currentMember = CURRENT_MEMBER, + currentSettings = {}, currentGuest = null, currentProfile = MEMBER_PUBLIC_PROFILE, storageAmountInBytes = 10000, @@ -537,6 +539,14 @@ Cypress.Commands.add( mockRejectMembershipRequest(); mockEnroll(); + + const completeCurrentSettings = { + marketingEmailsSubscribedAt: new Date().toISOString(), + notificationFrequency: currentMember.extra.emailFreq, + enableSaveActions: currentMember.enableSaveActions, + ...currentSettings, + }; + mockGetCurrentSettings(completeCurrentSettings); }, ); diff --git a/cypress/support/server.ts b/cypress/support/server.ts index 4b22ea8b3..f01830fab 100644 --- a/cypress/support/server.ts +++ b/cypress/support/server.ts @@ -31,7 +31,7 @@ import { CyHttpMessages } from 'cypress/types/net-stubbing'; import { StatusCodes } from 'http-status-codes'; import { v4 } from 'uuid'; -import { Profile } from '@/openapi/client/types.gen'; +import { CurrentSettings, Profile } from '@/openapi/client/types.gen'; import { ITEM_PAGE_SIZE, SETTINGS } from '../../src/modules/builder/constants'; import { API_ROUTES } from '../../src/query/routes'; @@ -73,7 +73,6 @@ const { buildDownloadFilesRoute, buildEditItemRoute, buildExportItemChatRoute, - buildGetCurrentMemberRoute, buildGetItemChatRoute, buildGetItemGeolocationRoute, buildGetItemInvitationsForItemRoute, @@ -197,14 +196,14 @@ export const mockGetCurrentMember = ( cy.intercept( { method: HttpMethod.Get, - pathname: `/${buildGetCurrentMemberRoute()}`, + pathname: /\/members\/current$/, }, handler, ).as('getCurrentMember'); cy.intercept( { method: HttpMethod.Get, - pathname: `/api/${buildGetCurrentMemberRoute()}`, + pathname: /\/api\/members\/current$/, }, handler, ).as('getCurrentMemberAPI'); @@ -2398,3 +2397,17 @@ export const mockGetItemMembershipsForItem = ( }, ).as('getItemMemberships'); }; + +export const mockGetCurrentSettings = ( + currentSettings: CurrentSettings, +): void => { + cy.intercept( + { + method: HttpMethod.Get, + pathname: /\/api\/members\/current\/settings$/, + }, + ({ reply }) => { + reply(currentSettings); + }, + ).as('getCurrentSettings'); +}; diff --git a/cypress/support/types.ts b/cypress/support/types.ts index 7ab739e40..4cf5f64e6 100644 --- a/cypress/support/types.ts +++ b/cypress/support/types.ts @@ -1,4 +1,4 @@ -import { +import type { ChatMention, ChatMessage, CompleteGuest, @@ -23,7 +23,11 @@ import { ThumbnailsBySize, } from '@graasp/sdk'; -import { ItemVisibility, Profile } from '@/openapi/client/types.gen'; +import type { + CurrentSettings, + ItemVisibility, + Profile, +} from '@/openapi/client/types.gen'; export type ItemForTest = DiscriminatedItem & { geolocation?: Partial; @@ -48,6 +52,7 @@ export type FileItemForTest = FileItemType & { }; export type ApiConfig = { currentGuest?: CompleteGuest | null; + currentSettings?: Partial; hasPassword?: boolean; currentProfile?: Profile | null; getCurrentProfileError?: boolean; diff --git a/src/config/selectors.ts b/src/config/selectors.ts index fef263bf8..b0cb82285 100644 --- a/src/config/selectors.ts +++ b/src/config/selectors.ts @@ -9,6 +9,8 @@ export const SETTINGS_PAGE_CONTAINER_ID = 'settings-page-container'; export const PREFERENCES_LANGUAGE_SWITCH_ID = 'preferences-language-switch'; export const PREFERENCES_LANGUAGE_DISPLAY_ID = 'preferences-language-display'; +export const PREFERENCES_MARKETING_SUBSCRIPTION_DISPLAY_ID = + 'preferences-marketing-subscription-display'; export const PREFERENCES_ANALYTICS_SWITCH_ID = 'preferences-analytics-switch'; export const PREFERENCES_EMAIL_FREQUENCY_ID = 'preferences-email-frequency'; export const PREFERENCES_EDIT_BUTTON_ID = 'preferences-edit-button'; diff --git a/src/locales/en/account.json b/src/locales/en/account.json index 741d734b9..8adfd7429 100644 --- a/src/locales/en/account.json +++ b/src/locales/en/account.json @@ -21,7 +21,12 @@ "PROFILE_LANGUAGE_TITLE": "Language", "PROFILE_CREATED_AT_INFO": "Member since {{date}}", "PROFILE_EMAIL_TITLE": "Email", - "PROFILE_EMAIL_FREQUENCY_TITLE": "Email Frequency", + "PROFILE_EMAIL_FREQUENCY_TITLE": "Notification Frequency", + "PROFILE_ENABLE_EMAIL_SUBSCRIPTION": { + "TITLE": "I want to receive Graasp's updates and communication", + "ENABLED": "Enabled", + "DISABLED": "Disabled" + }, "PROFILE_SAVE_ACTIONS_TITLE": "Enable Analytics", "PROFILE_SAVE_ACTIONS_ENABLED": "Enabled", "PROFILE_SAVE_ACTIONS_DISABLED": "Disabled", diff --git a/src/locales/fr/account.json b/src/locales/fr/account.json index f759bcdb5..53d0b52dc 100644 --- a/src/locales/fr/account.json +++ b/src/locales/fr/account.json @@ -21,7 +21,12 @@ "PROFILE_LANGUAGE_TITLE": "Langue", "PROFILE_CREATED_AT_INFO": "Membre depuis {{date}}", "PROFILE_EMAIL_TITLE": "Email", - "PROFILE_EMAIL_FREQUENCY_TITLE": "Fréquence d'envoi d'email", + "PROFILE_EMAIL_FREQUENCY_TITLE": "Fréquence d'envoi des notifications", + "PROFILE_ENABLE_EMAIL_SUBSCRIPTION": { + "TITLE": "Je veux recevoir les informations sur les mises à jour et communication de Graasp", + "ENABLED": "Activé", + "DISABLED": "Désactivé" + }, "PROFILE_SAVE_ACTIONS_TITLE": "Activer la sauvegarde des interactions", "PROFILE_SAVE_ACTIONS_ENABLED": "Activé", "PROFILE_SAVE_ACTIONS_DISABLED": "Désactivé", diff --git a/src/modules/account/settings/SettingItem.tsx b/src/modules/account/settings/SettingItem.tsx index 53cecb39e..930aad89e 100644 --- a/src/modules/account/settings/SettingItem.tsx +++ b/src/modules/account/settings/SettingItem.tsx @@ -5,7 +5,7 @@ import { Stack, Typography } from '@mui/material'; type Props = { title: string; content?: ReactNode; - contentId: string; + contentId?: string; }; export function SettingItem({ title, diff --git a/src/modules/account/settings/pages/SettingsPage.tsx b/src/modules/account/settings/pages/SettingsPage.tsx new file mode 100644 index 000000000..508676bc4 --- /dev/null +++ b/src/modules/account/settings/pages/SettingsPage.tsx @@ -0,0 +1,72 @@ +import { type JSX, Suspense } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Skeleton, Stack } from '@mui/material'; + +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { ScreenLayout } from '@/components/layout/ScreenLayout'; +import { DEFAULT_LANG, NS } from '@/config/constants'; +import { SETTINGS_PAGE_CONTAINER_ID } from '@/config/selectors'; +import { MemberCard } from '@/modules/home/MemberCard'; +import { getCurrentSettingsOptions } from '@/openapi/client/@tanstack/react-query.gen'; + +import { DeleteMemberSection } from '~account/settings/DeleteMemberSection'; +import { ExportMemberData } from '~account/settings/ExportMemberData'; +import { Password } from '~account/settings/password/Password'; +import { Preferences } from '~account/settings/preferences/Preferences'; +import { PersonalInformation } from '~account/settings/profile/PersonalInformation'; +import { PublicProfile } from '~account/settings/publicProfile/PublicProfile'; + +export function SettingsPage(): JSX.Element { + return ( + }> + + + ); +} + +function Settings(): JSX.Element { + const { t } = useTranslation(NS.Account); + const { data: memberSettings } = useSuspenseQuery( + getCurrentSettingsOptions(), + ); + + const { + lang, + enableSaveActions, + notificationFrequency, + marketingEmailsSubscribedAt, + } = memberSettings; + + return ( + + + + + + + + + + ); +} + +function SettingsLoader() { + return ( + + + + + + + ); +} diff --git a/src/modules/account/settings/preferences/EditPreferences.tsx b/src/modules/account/settings/preferences/EditPreferences.tsx index c2354e23c..b624a1ab5 100644 --- a/src/modules/account/settings/preferences/EditPreferences.tsx +++ b/src/modules/account/settings/preferences/EditPreferences.tsx @@ -3,86 +3,80 @@ import { useTranslation } from 'react-i18next'; import { Alert, Stack, Switch, Tooltip } from '@mui/material'; -import { CompleteMember } from '@graasp/sdk'; - -import { useMutation, useQueryClient } from '@tanstack/react-query'; - import { BorderedSection } from '@/components/layout/BorderedSection'; import FormProperty from '@/components/layout/FormProperty'; import { Button } from '@/components/ui/Button'; import LanguageSwitch from '@/components/ui/LanguageSwitch'; -import { DEFAULT_EMAIL_FREQUENCY, DEFAULT_LANG, NS } from '@/config/constants'; +import { NS } from '@/config/constants'; import { PREFERENCES_ANALYTICS_SWITCH_ID, PREFERENCES_CANCEL_BUTTON_ID, PREFERENCES_EDIT_CONTAINER_ID, - PREFERENCES_EMAIL_FREQUENCY_ID, PREFERENCES_LANGUAGE_SWITCH_ID, PREFERENCES_SAVE_BUTTON_ID, } from '@/config/selectors'; -import { updateCurrentAccountMutation } from '@/openapi/client/@tanstack/react-query.gen'; -import { memberKeys } from '@/query/keys'; +import type { CurrentSettings, NotificationFrequency } from '@/openapi/client'; -import { EmailPreferenceSwitch } from '../EmailPreferenceSwitch'; +import { EmailPreferenceSwitch } from './EmailPreferenceSwitch'; +import { MarketingEmailsSubscribeSwitch } from './MarketingEmailsSubscribeSwitch'; +import { useUpdatePreferences } from './useUpdatePreferences'; -type EditPreferencesProp = { - readonly member: CompleteMember; - readonly onClose: () => void; -}; +type EditPreferencesProp = Readonly<{ + lang: string; + enableSaveActions: boolean; + notificationFrequency: NotificationFrequency; + marketingEmailsSubscribedAt: CurrentSettings['marketingEmailsSubscribedAt']; + onClose: () => void; +}>; export function EditPreferences({ - member, + lang, + enableSaveActions, + marketingEmailsSubscribedAt, + notificationFrequency, onClose, }: EditPreferencesProp): JSX.Element { const { i18n, t } = useTranslation(NS.Account); const { t: translateCommon } = useTranslation(NS.Common); const { t: translateMessage } = useTranslation(NS.Messages); - const queryClient = useQueryClient(); - const { mutateAsync: editMember, error } = useMutation({ - ...updateCurrentAccountMutation(), - onSettled: async () => { - await queryClient.invalidateQueries({ - queryKey: memberKeys.current().content, - }); - }, + const { saveSettings, error } = useUpdatePreferences({ + marketingEmailsSubscribedAt, }); - const memberLang = member?.extra?.lang ?? DEFAULT_LANG; - const memberEmailFreq = member?.extra?.emailFreq ?? DEFAULT_EMAIL_FREQUENCY; - const memberSaveActions = member?.enableSaveActions ?? true; - - const [selectedLang, setSelectedLang] = useState(memberLang); - const [selectedEmailFreq, setSelectedEmailFreq] = useState(memberEmailFreq); + const [selectedLang, setSelectedLang] = useState(lang); + const [ + selectedIsSubscribedToMarketingEmails, + setSelectedIsSubscribedToMarketingEmails, + ] = useState(Boolean(marketingEmailsSubscribedAt)); + const [selectedNotificationFrequency, setSelectedNotificationFrequency] = + useState(notificationFrequency); const [switchedSaveActions, setSwitchedSaveActions] = - useState(memberSaveActions); + useState(enableSaveActions); const handleOnToggle = (event: { target: { checked: boolean } }): void => { const { checked } = event.target; setSwitchedSaveActions(checked); }; - const saveSettings = async () => { - try { - await editMember({ - body: { - extra: { - lang: selectedLang, - emailFreq: selectedEmailFreq, - }, - enableSaveActions: switchedSaveActions, - }, - }); - if (selectedLang !== memberLang) { - i18n.changeLanguage(selectedLang); - } - onClose(); - } catch (e) { - console.error(e); + + const onSubmit = () => { + saveSettings({ + lang: selectedLang, + enableSaveActions: switchedSaveActions, + notificationFrequency: selectedNotificationFrequency, + isSubscribedToMarketingEmails: selectedIsSubscribedToMarketingEmails, + }); + + if (selectedLang !== lang) { + i18n.changeLanguage(selectedLang); } + onClose(); }; const hasChanges = - selectedLang !== memberLang || - selectedEmailFreq !== memberEmailFreq || - switchedSaveActions !== memberSaveActions; + selectedLang !== lang || + selectedIsSubscribedToMarketingEmails !== + Boolean(marketingEmailsSubscribedAt) || + selectedNotificationFrequency !== notificationFrequency || + switchedSaveActions !== enableSaveActions; return ( - - - + + + + + + + + {error && ( {translateMessage('EDIT_MEMBER_ERROR')} )} @@ -128,7 +129,7 @@ export function EditPreferences({