From 6b1fd77383c3cbf4fe2a6e79525af180700e26e0 Mon Sep 17 00:00:00 2001 From: Yabir Benchakhtir Date: Fri, 17 Jul 2026 14:14:57 +0200 Subject: [PATCH] feat(newsletter): add email preferences to settings --- .../src/schemas/account.ts | 1 + .../account/home/AccountInformation.vue | 14 ++- .../account/home/EmailPreferences.vue | 102 +++++++++++++++++ .../composables/account/use-account-api.ts | 9 +- .../composables/account/use-profile-update.ts | 14 ++- packages/website/app/layouts/account.vue | 4 + .../app/pages/home/email-preferences.vue | 15 +++ packages/website/app/types/account.ts | 1 + packages/website/app/types/common.ts | 6 +- packages/website/app/types/index.ts | 3 + packages/website/i18n/locales/en.json | 6 + .../e2e/specs/pages/payment-redirect.spec.ts | 1 + .../account/home/AccountInformation.spec.ts | 83 ++++++++++++++ .../account/home/EmailPreferences.spec.ts | 84 ++++++++++++++ .../unit/composables/use-account-api.spec.ts | 82 ++++++++++++++ .../composables/use-profile-update.spec.ts | 105 ++++++++++++++++++ 16 files changed, 520 insertions(+), 10 deletions(-) create mode 100644 packages/website/app/components/account/home/EmailPreferences.vue create mode 100644 packages/website/app/pages/home/email-preferences.vue create mode 100644 packages/website/tests/unit/components/account/home/AccountInformation.spec.ts create mode 100644 packages/website/tests/unit/components/account/home/EmailPreferences.spec.ts create mode 100644 packages/website/tests/unit/composables/use-account-api.spec.ts create mode 100644 packages/website/tests/unit/composables/use-profile-update.spec.ts diff --git a/packages/card-payment-common/src/schemas/account.ts b/packages/card-payment-common/src/schemas/account.ts index c68551220..f323b4115 100644 --- a/packages/card-payment-common/src/schemas/account.ts +++ b/packages/card-payment-common/src/schemas/account.ts @@ -38,6 +38,7 @@ export const AccountSchema = z.object({ email: z.string().min(1), emailConfirmed: z.boolean(), hasActiveSubscription: z.boolean(), + newsletterConsent: z.boolean(), username: z.string().min(1), vat: z.number(), vatIdStatus: z.string(), diff --git a/packages/website/app/components/account/home/AccountInformation.vue b/packages/website/app/components/account/home/AccountInformation.vue index ee68e207a..9a1c7fe3e 100644 --- a/packages/website/app/components/account/home/AccountInformation.vue +++ b/packages/website/app/components/account/home/AccountInformation.vue @@ -12,7 +12,14 @@ import { toMessages } from '~/utils/validation'; const { t } = useI18n({ useScope: 'global' }); -const state = reactive({ +interface ProfileFormState { + companyName: string; + firstName: string; + lastName: string; + vatId: string; +} + +const state = reactive({ firstName: '', lastName: '', companyName: '', @@ -94,7 +101,7 @@ const vatStatusErrorMessage = computed(() => { return ''; }); -function reset() { +function reset(): void { const userAccount = get(account); if (!userAccount) @@ -114,7 +121,7 @@ function reset() { get(v$).$reset(); } -async function update() { +async function update(): Promise { await updateProfile(v$, state); } @@ -265,6 +272,7 @@ onMounted(() => { {{ t('actions.reset') }} +import { useVuelidate } from '@vuelidate/core'; +import { get } from '@vueuse/shared'; +import FloatingNotification from '~/components/account/home/FloatingNotification.vue'; +import { useProfileUpdate } from '~/composables/account/use-profile-update'; + +interface EmailPreferencesFormState { + newsletterConsent: boolean; +} + +const { t } = useI18n({ useScope: 'global' }); + +const state = reactive({ + newsletterConsent: false, +}); + +const { + $externalResults, + account, + done, + loading, + updateProfile, +} = useProfileUpdate(); + +const rules = { + newsletterConsent: {}, +}; + +const v$ = useVuelidate(rules, state, { + $autoDirty: true, + $externalResults, +}); + +function reset(): void { + const userAccount = get(account); + if (!userAccount) + return; + + state.newsletterConsent = userAccount.newsletterConsent; + get(v$).$reset(); +} + +async function update(): Promise { + await updateProfile(v$, state); +} + +onBeforeMount(() => { + reset(); +}); + + + diff --git a/packages/website/app/composables/account/use-account-api.ts b/packages/website/app/composables/account/use-account-api.ts index 2723dd8d0..bbff6a193 100644 --- a/packages/website/app/composables/account/use-account-api.ts +++ b/packages/website/app/composables/account/use-account-api.ts @@ -1,6 +1,6 @@ import type { ComposerTranslation } from 'vue-i18n'; import type { DeleteAccountPayload, PasswordChangePayload, ProfilePayload } from '~/types/account'; -import type { ActionResult } from '~/types/common'; +import type { ActionResult, ProfileUpdateResult } from '~/types/common'; import { type Account, AccountSchema } from '@rotki/card-payment-common/schemas/account'; import { type ActionResultResponse, @@ -55,7 +55,7 @@ export function useAccountApi() { /** * Update user profile information */ - const updateProfile = async (payload: ProfilePayload): Promise => { + const updateProfile = async (payload: ProfilePayload): Promise => { try { const response = await fetchWithCsrf( '/webapi/account/', @@ -74,7 +74,10 @@ export function useAccountApi() { } const { result } = parsed.data; if (result) { - return createSuccessResult(); + return { + ...createSuccessResult(), + profile: result, + }; } return { diff --git a/packages/website/app/composables/account/use-profile-update.ts b/packages/website/app/composables/account/use-profile-update.ts index faa5e35f2..43feb16f9 100644 --- a/packages/website/app/composables/account/use-profile-update.ts +++ b/packages/website/app/composables/account/use-profile-update.ts @@ -1,4 +1,3 @@ -import type { Validation } from '@vuelidate/core'; import type { ProfilePayload } from '~/types/account'; import { objectOmit } from '@vueuse/core'; import { get, set } from '@vueuse/shared'; @@ -9,6 +8,10 @@ import { useMainStore } from '~/store'; type ProfileOmitFields = 'movedOffline'; +interface ProfileValidation { + $validate: () => Promise; +} + export function useProfileUpdate() { const store = useMainStore(); const { account } = storeToRefs(store); @@ -25,7 +28,7 @@ export function useProfileUpdate() { * Update user profile with validated form data */ const updateProfile = async >( - v$: Ref, + v$: Ref, state: T, omitFields: ProfileOmitFields[] = ['movedOffline'], ): Promise => { @@ -49,7 +52,12 @@ export function useProfileUpdate() { const result = await accountApi.updateProfile(payload); - if (result.success) { + if (result.success && result.profile) { + set(account, { + ...userAccount, + address: result.profile.address, + newsletterConsent: result.profile.newsletterConsent, + }); requestRefresh(); } diff --git a/packages/website/app/layouts/account.vue b/packages/website/app/layouts/account.vue index a7afba063..1595bcb29 100644 --- a/packages/website/app/layouts/account.vue +++ b/packages/website/app/layouts/account.vue @@ -59,6 +59,10 @@ const tabs = computed(() => { label: t('account.tabs.account_details'), icon: 'lu-circle-user-round', to: '/home/account-details', + }, { + label: t('account.tabs.email_preferences'), + icon: 'lu-mail', + to: '/home/email-preferences', }, { label: t('account.tabs.customer_information'), icon: 'lu-info', diff --git a/packages/website/app/pages/home/email-preferences.vue b/packages/website/app/pages/home/email-preferences.vue new file mode 100644 index 000000000..43a80c5d8 --- /dev/null +++ b/packages/website/app/pages/home/email-preferences.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/website/app/types/account.ts b/packages/website/app/types/account.ts index 00a4c0ed8..a2b1f6557 100644 --- a/packages/website/app/types/account.ts +++ b/packages/website/app/types/account.ts @@ -19,6 +19,7 @@ export interface ProfilePayload { readonly city?: string; readonly postcode?: string; readonly country?: string; + readonly newsletterConsent?: boolean; } export interface DeleteAccountPayload { diff --git a/packages/website/app/types/common.ts b/packages/website/app/types/common.ts index d6a71f274..39e2961fc 100644 --- a/packages/website/app/types/common.ts +++ b/packages/website/app/types/common.ts @@ -1,10 +1,14 @@ -import type { ApiError } from '~/types/index'; +import type { ApiError, UpdateProfile } from '~/types/index'; export interface ActionResult { readonly success: boolean; readonly message?: ApiError; } +export interface ProfileUpdateResult extends ActionResult { + readonly profile?: UpdateProfile; +} + export interface PayEvent { planId: number; paymentMethodNonce: string; diff --git a/packages/website/app/types/index.ts b/packages/website/app/types/index.ts index 5a1b0d85e..d52601e50 100644 --- a/packages/website/app/types/index.ts +++ b/packages/website/app/types/index.ts @@ -61,8 +61,11 @@ export type ChangePasswordResponse = z.infer; const UpdateProfile = z.object({ address: AddressSchema, + newsletterConsent: z.boolean(), }); +export type UpdateProfile = z.infer; + export const UpdateProfileResponse = z.object({ message: ApiError.optional(), result: UpdateProfile.optional(), diff --git a/packages/website/i18n/locales/en.json b/packages/website/i18n/locales/en.json index 1f0e5d322..fce8c8ee6 100644 --- a/packages/website/i18n/locales/en.json +++ b/packages/website/i18n/locales/en.json @@ -220,6 +220,7 @@ "address": "Address", "customer_information": "Customer Information", "devices": "Devices List", + "email_preferences": "Email Preferences", "subscription": "Subscription", "payment_methods": "Saved Cards" }, @@ -242,6 +243,11 @@ "check_email": "To change your email, please contact our support tem via", "title": "Details" }, + "email_preferences": { + "description": "Choose whether you receive optional emails from rotki.", + "newsletter": "Send me product updates and news from rotki.", + "title": "Email Preferences" + }, "actions": { "add_card": "Add card", "apply": "Apply", diff --git a/packages/website/tests/e2e/specs/pages/payment-redirect.spec.ts b/packages/website/tests/e2e/specs/pages/payment-redirect.spec.ts index 71576dd41..0041fa681 100644 --- a/packages/website/tests/e2e/specs/pages/payment-redirect.spec.ts +++ b/packages/website/tests/e2e/specs/pages/payment-redirect.spec.ts @@ -18,6 +18,7 @@ const mockAuthenticatedAccount = { email: 'test@example.com', email_confirmed: true, has_active_subscription: false, + newsletter_consent: false, can_use_premium: false, api_key: '', api_secret: '', diff --git a/packages/website/tests/unit/components/account/home/AccountInformation.spec.ts b/packages/website/tests/unit/components/account/home/AccountInformation.spec.ts new file mode 100644 index 000000000..f0c232e98 --- /dev/null +++ b/packages/website/tests/unit/components/account/home/AccountInformation.spec.ts @@ -0,0 +1,83 @@ +import type { Account } from '@rotki/card-payment-common/schemas/account'; +import { mountSuspended } from '@nuxt/test-utils/runtime'; +import { describe, expect, it, vi } from 'vitest'; +import { computed, ref } from 'vue'; +import AccountInformation from '~/components/account/home/AccountInformation.vue'; + +const mockUseProfileUpdate = vi.hoisted(() => vi.fn()); + +vi.mock('~/composables/account/use-profile-update', () => ({ + useProfileUpdate: mockUseProfileUpdate, +})); + +function createAccount(newsletterConsent: boolean): Account { + return { + address: { + address1: 'First street', + address2: 'Second street', + city: 'Berlin', + companyName: 'rotki', + country: 'DE', + firstName: 'Alice', + lastName: 'Example', + movedOffline: false, + postcode: '10115', + vatId: 'DE123456789', + }, + apiKey: 'api-key', + apiSecret: 'api-secret', + canUsePremium: true, + dateNow: '2026-07-17', + email: 'alice@example.com', + emailConfirmed: true, + hasActiveSubscription: true, + newsletterConsent, + username: 'alice', + vat: 19, + vatIdStatus: 'Valid', + }; +} + +async function mountAccountInformation(newsletterConsent: boolean) { + const updateProfile = vi.fn().mockResolvedValue(true); + const account = ref(createAccount(newsletterConsent)); + + mockUseProfileUpdate.mockReturnValue({ + $externalResults: ref>({}), + account, + done: ref(false), + loading: ref(false), + movedOffline: computed(() => false), + updateProfile, + }); + + const wrapper = await mountSuspended(AccountInformation, { + global: { + stubs: { + FloatingNotification: true, + }, + }, + }); + + return { updateProfile, wrapper }; +} + +describe('account information', () => { + it('continues to submit the existing customer information fields', async () => { + const { updateProfile, wrapper } = await mountAccountInformation(false); + await wrapper.get('[data-cy="update-profile"]').trigger('click'); + + await vi.waitFor(() => { + expect(updateProfile).toHaveBeenCalledOnce(); + }); + expect(updateProfile).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyName: 'rotki', + firstName: 'Alice', + lastName: 'Example', + vatId: 'DE123456789', + }), + ); + }); +}); diff --git a/packages/website/tests/unit/components/account/home/EmailPreferences.spec.ts b/packages/website/tests/unit/components/account/home/EmailPreferences.spec.ts new file mode 100644 index 000000000..14585fada --- /dev/null +++ b/packages/website/tests/unit/components/account/home/EmailPreferences.spec.ts @@ -0,0 +1,84 @@ +import type { Account } from '@rotki/card-payment-common/schemas/account'; +import { mountSuspended } from '@nuxt/test-utils/runtime'; +import { describe, expect, it, vi } from 'vitest'; +import { computed, ref } from 'vue'; +import EmailPreferences from '~/components/account/home/EmailPreferences.vue'; + +const mockUseProfileUpdate = vi.hoisted(() => vi.fn()); + +vi.mock('~/composables/account/use-profile-update', () => ({ + useProfileUpdate: mockUseProfileUpdate, +})); + +function createAccount(newsletterConsent: boolean): Account { + return { + address: { + address1: 'First street', + address2: 'Second street', + city: 'Berlin', + companyName: 'rotki', + country: 'DE', + firstName: 'Alice', + lastName: 'Example', + movedOffline: false, + postcode: '10115', + vatId: 'DE123456789', + }, + apiKey: 'api-key', + apiSecret: 'api-secret', + canUsePremium: true, + dateNow: '2026-07-17', + email: 'alice@example.com', + emailConfirmed: true, + hasActiveSubscription: true, + newsletterConsent, + username: 'alice', + vat: 19, + vatIdStatus: 'Valid', + }; +} + +async function mountEmailPreferences(newsletterConsent: boolean) { + const updateProfile = vi.fn().mockResolvedValue(true); + + mockUseProfileUpdate.mockReturnValue({ + $externalResults: ref>({}), + account: ref(createAccount(newsletterConsent)), + done: ref(false), + loading: ref(false), + movedOffline: computed(() => false), + updateProfile, + }); + + const wrapper = await mountSuspended(EmailPreferences, { + global: { + stubs: { + FloatingNotification: true, + }, + }, + }); + + return { updateProfile, wrapper }; +} + +describe('email preferences', () => { + it.each([false, true])('initializes newsletter consent to %s from account data', async (newsletterConsent) => { + const { wrapper } = await mountEmailPreferences(newsletterConsent); + + expect(wrapper.get('#newsletter-consent').element.checked).toBe(newsletterConsent); + }); + + it.each([false, true])('submits newsletter consent as %s', async (newsletterConsent) => { + const { updateProfile, wrapper } = await mountEmailPreferences(!newsletterConsent); + await wrapper.get('#newsletter-consent').setValue(newsletterConsent); + await wrapper.get('[data-cy="update-email-preferences"]').trigger('click'); + + await vi.waitFor(() => { + expect(updateProfile).toHaveBeenCalledOnce(); + }); + expect(updateProfile).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ newsletterConsent }), + ); + }); +}); diff --git a/packages/website/tests/unit/composables/use-account-api.spec.ts b/packages/website/tests/unit/composables/use-account-api.spec.ts new file mode 100644 index 000000000..8767507ca --- /dev/null +++ b/packages/website/tests/unit/composables/use-account-api.spec.ts @@ -0,0 +1,82 @@ +import type { Account, Address } from '@rotki/card-payment-common/schemas/account'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useAccountApi } from '~/composables/account/use-account-api'; + +const mockFetchWithCsrf = vi.hoisted(() => vi.fn()); + +vi.mock('~/composables/use-fetch-with-csrf', () => ({ + useFetchWithCsrf: () => ({ + fetchWithCsrf: mockFetchWithCsrf, + }), +})); + +const address: Address = { + address1: 'First street', + address2: 'Second street', + city: 'Berlin', + companyName: 'rotki', + country: 'DE', + firstName: 'Alice', + lastName: 'Example', + movedOffline: false, + postcode: '10115', + vatId: 'DE123456789', +}; + +const account: Account = { + address, + apiKey: 'api-key', + apiSecret: 'api-secret', + canUsePremium: true, + dateNow: '2026-07-17', + email: 'alice@example.com', + emailConfirmed: true, + hasActiveSubscription: true, + newsletterConsent: false, + username: 'alice', + vat: 19, + vatIdStatus: 'Valid', +}; + +describe('useAccountApi', () => { + afterEach(() => { + mockFetchWithCsrf.mockReset(); + }); + + it('reads the required newsletter consent from the account response', async () => { + mockFetchWithCsrf.mockResolvedValue({ result: account }); + + const result = await useAccountApi().getAccount(); + + expect(result?.newsletterConsent).toBe(false); + }); + + it('returns the updated newsletter consent and address from the PATCH response', async () => { + mockFetchWithCsrf.mockResolvedValue({ + result: { + address, + newsletterConsent: true, + }, + }); + + const result = await useAccountApi().updateProfile({ + firstName: 'Alice', + newsletterConsent: true, + }); + + expect(mockFetchWithCsrf).toHaveBeenCalledWith('/webapi/account/', { + body: { + firstName: 'Alice', + newsletterConsent: true, + }, + method: 'PATCH', + }); + expect(result).toEqual({ + profile: { + address, + newsletterConsent: true, + }, + success: true, + }); + }); +}); diff --git a/packages/website/tests/unit/composables/use-profile-update.spec.ts b/packages/website/tests/unit/composables/use-profile-update.spec.ts new file mode 100644 index 000000000..e8ec4f51e --- /dev/null +++ b/packages/website/tests/unit/composables/use-profile-update.spec.ts @@ -0,0 +1,105 @@ +import type { Account, Address } from '@rotki/card-payment-common/schemas/account'; +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import { useProfileUpdate } from '~/composables/account/use-profile-update'; +import { useMainStore } from '~/store'; + +const mockUpdateProfile = vi.hoisted(() => vi.fn()); +const mockRequestRefresh = vi.hoisted(() => vi.fn()); + +vi.mock('~/composables/account/use-account-api', () => ({ + useAccountApi: () => ({ + updateProfile: mockUpdateProfile, + }), +})); + +vi.mock('~/composables/use-app-events', () => ({ + useAccountRefresh: () => ({ + onRefresh: vi.fn(), + requestRefresh: mockRequestRefresh, + }), +})); + +const initialAddress: Address = { + address1: 'First street', + address2: 'Second street', + city: 'Berlin', + companyName: 'rotki', + country: 'DE', + firstName: 'Alice', + lastName: 'Example', + movedOffline: false, + postcode: '10115', + vatId: 'DE123456789', +}; + +function createAccount(): Account { + return { + address: initialAddress, + apiKey: 'api-key', + apiSecret: 'api-secret', + canUsePremium: true, + dateNow: '2026-07-17', + email: 'alice@example.com', + emailConfirmed: true, + hasActiveSubscription: true, + newsletterConsent: true, + username: 'alice', + vat: 19, + vatIdStatus: 'Valid', + }; +} + +describe('useProfileUpdate', () => { + beforeEach(() => { + setActivePinia(createPinia()); + mockUpdateProfile.mockReset(); + mockRequestRefresh.mockReset(); + }); + + it('updates local profile state from the PATCH response', async () => { + const store = useMainStore(); + store.account = createAccount(); + const responseAddress: Address = { + ...initialAddress, + companyName: 'Updated company', + firstName: 'Updated', + }; + mockUpdateProfile.mockResolvedValue({ + profile: { + address: responseAddress, + newsletterConsent: false, + }, + success: true, + }); + const validation = ref<{ $validate: () => Promise }>({ + $validate: vi.fn().mockResolvedValue(true), + }); + const { updateProfile } = useProfileUpdate(); + + const success = await updateProfile(validation, { + firstName: 'Updated', + newsletterConsent: false, + }); + + expect(success).toBe(true); + expect(mockUpdateProfile).toHaveBeenCalledWith({ + address1: initialAddress.address1, + address2: initialAddress.address2, + city: initialAddress.city, + companyName: initialAddress.companyName, + country: initialAddress.country, + firstName: 'Updated', + lastName: initialAddress.lastName, + newsletterConsent: false, + postcode: initialAddress.postcode, + vatId: initialAddress.vatId, + }); + expect(store.account).toMatchObject({ + address: responseAddress, + newsletterConsent: false, + }); + expect(mockRequestRefresh).toHaveBeenCalledOnce(); + }); +});