Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
948eadb
initial page redesign
libby-correctiv Jul 10, 2026
b013814
change validation to valibot
libby-correctiv Jul 13, 2026
1036eb5
first code refactors
libby-correctiv Jul 13, 2026
9b08cd9
valibot -> zod
libby-correctiv Jul 13, 2026
d809519
add composable, stickysavebar, USkeleton placeholders, other refactors
libby-correctiv Jul 13, 2026
bb4efc7
add addformskeleton
libby-correctiv Jul 14, 2026
40da0de
yarn build for translations
libby-correctiv Jul 14, 2026
02dc150
remove unecessary comment
libby-correctiv Jul 14, 2026
c1a983d
translations
libby-correctiv Jul 14, 2026
c7c9b5d
Skeleton styling changes
libby-correctiv Jul 15, 2026
126ce1b
fix colours, remove unnecessary comments, small changes
libby-correctiv Jul 15, 2026
7f6bb34
2fa modal migrations, index.ts updates
libby-correctiv Jul 16, 2026
37e8d76
initial 2fa modals migration
libby-correctiv Jul 20, 2026
7035f1b
change appmodaldialog to appmodalheader
libby-correctiv Jul 20, 2026
95ebac4
big fixes
libby-correctiv Jul 21, 2026
49d637e
Remove AppModalHeader - use default UModal header
libby-correctiv Jul 24, 2026
edee189
fix(locale): split changed account/mfa strings for nuxt frontend
libby-correctiv Jul 24, 2026
0725385
add comment
libby-correctiv Jul 24, 2026
2b21d52
chore(vue): remove unused reka-ui dependency
libby-correctiv Jul 24, 2026
dce8981
fix(frontend): don't use -nuxt copy in unmigrated components
libby-correctiv Jul 24, 2026
b2b408d
feat(vue): add AppCopyIconButton, drop @vueuse/core
libby-correctiv Jul 27, 2026
400ea5e
fix(vue): correctly detect incomplete 2FA codes with mid-array holes
libby-correctiv Jul 27, 2026
6767205
remove unnecessary comments
libby-correctiv Jul 27, 2026
872587e
fix(vue): clean up stale/unnecessary doc comments, unify addressLine2
libby-correctiv Jul 27, 2026
c2b830e
refactor(frontend): make SetMFA enable/disable naming consistent, har…
libby-correctiv Jul 27, 2026
3b721c8
docs(frontend): explain .nuxt-page typography scope
libby-correctiv Jul 27, 2026
9063d2d
refactor(frontend): use single reactive state in ChangePassword
libby-correctiv Jul 27, 2026
f78abf6
Dark mode styling
libby-correctiv Aug 4, 2026
aebf63a
Update comment for clarity
libby-correctiv Aug 10, 2026
a801b4e
Improve dirty state computation
libby-correctiv Aug 10, 2026
129165b
create tab layout in account page
libby-correctiv Aug 10, 2026
e2a9c88
add newsletter card to subscriptions tab, with unsubscribe actions
libby-correctiv Aug 12, 2026
24f0841
change to use partial group updates to avoid lost writes
libby-correctiv Aug 18, 2026
d865bf4
prettier format and tabs cursor fix
libby-correctiv Aug 18, 2026
d59db03
add note that group names are shown to members in the integrations page
libby-correctiv Aug 19, 2026
e621a25
Add retry limit to upsertContact
libby-correctiv Aug 19, 2026
e8db84a
prettier
libby-correctiv Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/backend/src/api/controllers/ContactController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Contact } from '@beabee/core/models';
import ContactMfaService from '@beabee/core/services/ContactMfaService';
import ContactsService from '@beabee/core/services/ContactsService';
import DispatchService from '@beabee/core/services/DispatchService';
import NewsletterService from '@beabee/core/services/NewsletterService';
import PaymentFlowService from '@beabee/core/services/PaymentFlowService';
import PaymentService from '@beabee/core/services/PaymentService';
import { AuthInfo } from '@beabee/core/type';
Expand All @@ -36,6 +37,7 @@ import {
import { CurrentAuth } from '#api/decorators/CurrentAuth';
import PartialBody from '#api/decorators/PartialBody';
import { TargetUser } from '#api/decorators/TargetUser';
import { GetContactNewsletterGroupDto } from '#api/dto';
import { GetExportQuery } from '#api/dto/BaseDto';
import {
BatchUpdateContactDto,
Expand Down Expand Up @@ -500,4 +502,32 @@ export class ContactController {
throw new NotFoundError();
}
}

/**
* Get the newsletter groups a contact is currently subscribed to
* @param target The target contact
*/
@Get('/:id/newsletter-groups')
async getNewsletterGroups(
@TargetUser() target: Contact
): Promise<GetContactNewsletterGroupDto[]> {
const groups = await NewsletterService.getContactNewsletterGroups(
target.id
);
return plainToInstance(GetContactNewsletterGroupDto, groups);
}

/**
* Unsubscribe a contact from a single newsletter group
* @param target The target contact
* @param groupId The newsletter group ID
*/
@OnUndefined(204)
@Delete('/:id/newsletter-groups/:groupId')
async unsubscribeNewsletterGroup(
@TargetUser() target: Contact,
@Params() { groupId }: { groupId: string }
): Promise<void> {
await ContactsService.unsubscribeFromNewsletterGroup(target, groupId);
}
}
8 changes: 8 additions & 0 deletions apps/backend/src/api/dto/NewsletterDto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,11 @@ export class NewsletterGroupDto implements NewsletterGroupData {
@IsBoolean()
checked!: boolean;
}

export class GetContactNewsletterGroupDto {
@IsString()
id!: string;

@IsString()
label!: string;
}
1 change: 1 addition & 0 deletions apps/backend/src/api/dto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export * from './EmailDto.js';
export * from './HealthDto.js';
export * from './LinkDto.js';
export * from './LoginDto.js';
export * from './NewsletterDiffDto.js';
export * from './NewsletterDto.js';
export * from './NewsletterIntegrationDto.js';
export * from './NoticeDto.js';
Expand Down
4 changes: 3 additions & 1 deletion apps/frontend-old/src/type/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Auto-generated by `@beabee/dev-cli generate-index`. Do not edit by hand.
export * from './app-qr-code-error-correction-level';
export * from './app-qr-code-props';
export * from './app-qr-code-type-number';
Expand All @@ -8,6 +9,7 @@ export * from './email-editor';
export * from './formio';
export * from './geocode-pick-event';
export * from './get-callout-response-map-data-with-address';
export * from './integration';
export * from './item-with-status';
export * from './join-form-data';
export * from './locale-prop';
Expand All @@ -17,7 +19,7 @@ export * from './notification';
export * from './paginated';
export * from './payment-flow-form-data';
export * from './search';
export * from './selection-state';
export * from './set-mfa-steps';
export * from './set-mfa-totp-identity';
export * from './selection-state';
export * from './table';
3 changes: 2 additions & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
"vue": "^3.5.34",
"vue-maplibre-gl": "^3.1.3",
"vue-router": "^5.0.7",
"vuedraggable": "^4.1.0"
"vuedraggable": "^4.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/types": "^7.29.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
<AppCategoryLabel>
{{ t('adminSettings.integrations.newsletter.groups') }}
</AppCategoryLabel>
<AppTable v-if="groups?.length" :headers="groupHeaders" :items="groups">
<template #value-id="{ item }">
<span class="font-mono text-sm">{{ item.id }}</span>
</template>
</AppTable>
<template v-if="groups?.length">
<AppTable :headers="groupHeaders" :items="groups">
<template #value-id="{ item }">
<span class="font-mono text-sm">{{ item.id }}</span>
</template>
</AppTable>
<AppInputHelp
class="mt-3"
:message="t('adminSettings.integrations.newsletter.groupsNote')"
/>
</template>
<p v-else class="text-sm text-body-80">
{{ t('adminSettings.integrations.newsletter.noGroups') }}
</p>
Expand All @@ -17,7 +23,7 @@
</template>

<script lang="ts" setup>
import { AppCategoryLabel, AppTable } from '@beabee/vue';
import { AppCategoryLabel, AppInputHelp, AppTable } from '@beabee/vue';
import type { Header } from '@beabee/vue';
import { useI18n } from 'vue-i18n';

Expand Down
243 changes: 243 additions & 0 deletions apps/frontend/src/components/pages/profile/account/AccountForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
<!--
# AccountForm
Member-only ("me") contact info + delivery address form.
-->
<template>
<UForm
id="account-form"
ref="formRef"
class="flex flex-col gap-4"
:schema="schema"
:state="data"
@submit="handleSave"
>
<AppSectionCard
icon="i-lucide-user-round"
:title="t('accountPage.personalDetails')"
>
<AppFormSkeleton v-if="loading" :rows="3" />
<template v-else>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<UFormField :label="t('form.firstName')" required name="firstName">
<UInput v-model="data.firstName" class="w-full" />
</UFormField>
<UFormField :label="t('form.lastName')" required name="lastName">
<UInput v-model="data.lastName" class="w-full" />
</UFormField>
</div>

<UFormField :label="t('form.email')" required name="emailAddress">
<UInput v-model="data.emailAddress" type="email" class="w-full" />
</UFormField>

<UFormField
:label="t('form.phone')"
name="telephone"
:help="t('accountPage.phoneInfo-nuxt')"
>
<UInput v-model="data.telephone" type="tel" class="w-full" />
</UFormField>
</template>
</AppSectionCard>

<AppSectionCard
icon="i-lucide-map-pin"
:title="t('accountPage.deliveryAddress')"
>
<AppFormSkeleton v-if="loading" :rows="3" />
<template v-else>
<template v-if="accountContent?.showMailOptIn">
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<p class="text-default text-sm font-medium">
{{ accountContent.mailTitle }}
</p>
<div
class="text-muted text-sm"
v-html="accountContent.mailText"
/>
</div>
<USwitch
v-model="data.deliveryOptIn"
:label="accountContent.mailOptIn"
/>
</div>
</template>

<UFormField
:label="t('form.addressLine1')"
:required="data.deliveryOptIn"
name="addressLine1"
>
<UInput v-model="data.addressLine1" class="w-full" />
</UFormField>
<UFormField :label="t('form.addressLine2')">
<UInput v-model="data.addressLine2" class="w-full" />
</UFormField>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[2fr_1fr]">
<UFormField
:label="t('form.cityOrTown')"
:required="data.deliveryOptIn"
name="cityOrTown"
>
<UInput v-model="data.cityOrTown" class="w-full" />
</UFormField>
<UFormField
:label="t('form.postCode')"
:required="data.deliveryOptIn"
name="postCode"
>
<UInput v-model="data.postCode" class="w-full" />
</UFormField>
</div>
</template>
</AppSectionCard>

<AppStickySaveBar v-if="dirty" form="account-form" @cancel="handleCancel" />
</UForm>
</template>

<script lang="ts" setup>
import type { ContentData } from '@beabee/beabee-common';
import { GetContactWith, toPhoneNumber } from '@beabee/beabee-common';
import { AppFormSkeleton, AppSectionCard, AppStickySaveBar } from '@beabee/vue';

import { computed, onMounted, reactive, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { z } from 'zod';

import { useApiSubmit } from '#composables/useApiSubmit';
import { client } from '#utils/api';

const { t } = useI18n();

const loading = ref(true);
const accountContent = ref<ContentData<'join/setup'> | null>(null);

const data = reactive({
emailAddress: '',
firstName: '',
lastName: '',
telephone: '',
deliveryOptIn: false,
addressLine1: '',
addressLine2: '',
cityOrTown: '',
postCode: '',
});

/** Snapshot of the last-saved (or initially loaded) values, for Cancel */
let savedData = { ...data };

onMounted(async () => {
const [content, contact] = await Promise.all([
client.content.get('join/setup'),
client.contact.get('me', [GetContactWith.Profile]),
]);
accountContent.value = content;
Object.assign(data, {
emailAddress: contact.email,
firstName: contact.firstname,
lastName: contact.lastname,
telephone: contact.profile.telephone,
deliveryOptIn: contact.profile.deliveryOptIn,
addressLine1: contact.profile.deliveryAddress?.line1 || '',
addressLine2: contact.profile.deliveryAddress?.line2 || '',
cityOrTown: contact.profile.deliveryAddress?.city || '',
postCode: contact.profile.deliveryAddress?.postcode || '',
});

savedData = { ...data };
loading.value = false;
});

// Incomplete phone number validation
function isValidPhone(value: string): boolean {
if (!value) return true; // Optional field
return toPhoneNumber(value) !== false;
}

const schema = computed(() =>
z
.object({
emailAddress: z
.string()
.min(1, { error: t('form.errors.email.required'), abort: true })
.email({ error: t('form.errors.email.email') }),
firstName: z
.string()
.min(1, { error: t('form.errors.firstName.required') }),
lastName: z
.string()
.min(1, { error: t('form.errors.lastName.required') }),
telephone: z
.string()
.refine(isValidPhone, { error: t('form.errors.telephone.phone') }),
deliveryOptIn: z.boolean(),
addressLine1: z.string(),
cityOrTown: z.string(),
postCode: z.string(),
})
.refine((input) => !input.deliveryOptIn || !!input.addressLine1, {
error: t('form.errors.addressLine1.required'),
path: ['addressLine1'],
})
.refine((input) => !input.deliveryOptIn || !!input.cityOrTown, {
error: t('form.errors.cityOrTown.required'),
path: ['cityOrTown'],
})
.refine((input) => !input.deliveryOptIn || !!input.postCode, {
error: t('form.errors.postCode.required'),
path: ['postCode'],
})
);

// UForm validates in response to DOM events (input/blur/change), not
// reactively off `:state` — a silent `Object.assign` (in handleCancel below)
// doesn't fire those events, so any already-shown errors would otherwise
// stay stuck on screen. `formRef.value.clear()` explicitly wipes them.
const formRef = useTemplateRef('formRef');

const dirty = computed(
() =>
data.emailAddress !== savedData.emailAddress ||
data.firstName !== savedData.firstName ||
data.lastName !== savedData.lastName ||
data.telephone !== savedData.telephone ||
data.deliveryOptIn !== savedData.deliveryOptIn ||
data.addressLine1 !== savedData.addressLine1 ||
data.addressLine2 !== savedData.addressLine2 ||
data.cityOrTown !== savedData.cityOrTown ||
data.postCode !== savedData.postCode
);

const { submit: handleSave } = useApiSubmit(
async () => {
await client.contact.update('me', {
email: data.emailAddress,
firstname: data.firstName,
lastname: data.lastName,
profile: {
telephone: data.telephone,
// Only update opt in if it's visible
...(accountContent.value?.showMailOptIn && {
deliveryOptIn: data.deliveryOptIn,
}),
deliveryAddress: {
line1: data.addressLine1,
line2: data.addressLine2,
city: data.cityOrTown,
postcode: data.postCode,
},
},
});
savedData = { ...data };
},
{ successMessage: () => t('form.saved') }
);

function handleCancel() {
Object.assign(data, savedData);
formRef.value?.clear();
}
</script>
Loading
Loading