Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions backend/app/DomainObjects/Generated/UserDomainObjectAbstract.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ abstract class UserDomainObjectAbstract extends \HiEvents\DomainObjects\Abstract
final public const PENDING_EMAIL = 'pending_email';
final public const TIMEZONE = 'timezone';
final public const LOCALE = 'locale';
final public const MARKETING_OPTED_IN_AT = 'marketing_opted_in_at';

protected int $id;
protected string $email;
Expand All @@ -37,6 +38,7 @@ abstract class UserDomainObjectAbstract extends \HiEvents\DomainObjects\Abstract
protected ?string $pending_email = null;
protected string $timezone;
protected string $locale = 'en';
protected ?string $marketing_opted_in_at = null;

public function toArray(): array
{
Expand All @@ -54,6 +56,7 @@ public function toArray(): array
'pending_email' => $this->pending_email ?? null,
'timezone' => $this->timezone ?? null,
'locale' => $this->locale ?? null,
'marketing_opted_in_at' => $this->marketing_opted_in_at ?? null,
];
}

Expand Down Expand Up @@ -199,4 +202,15 @@ public function getLocale(): string
{
return $this->locale;
}

public function setMarketingOptedInAt(?string $marketing_opted_in_at): self
{
$this->marketing_opted_in_at = $marketing_opted_in_at;
return $this;
}

public function getMarketingOptedInAt(): ?string
{
return $this->marketing_opted_in_at;
}
}
1 change: 1 addition & 0 deletions backend/app/Http/Actions/Accounts/CreateAccountAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public function __invoke(CreateAccountRequest $request): JsonResponse
? $request->validated('locale')
: $this->localeService->getLocaleOrDefault($request->getPreferredLanguage()),
'invite_token' => $request->validated('invite_token'),
'marketing_opt_in' => (bool) $request->validated('marketing_opt_in'),
]));
} catch (EmailAlreadyExists $e) {
throw ValidationException::withMessages([
Expand Down
1 change: 1 addition & 0 deletions backend/app/Http/Actions/Users/UpdateMeAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public function __invoke(UpdateMeRequest $request): JsonResponse
'current_password' => $request->validated('current_password'),
'timezone' => $request->validated('timezone'),
'locale' => $request->validated('locale'),
'marketing_opt_in' => $request->has('marketing_opt_in') ? (bool) $request->validated('marketing_opt_in') : null,
]));

return $this->resourceResponse(UserResource::class, $user);
Expand Down
1 change: 1 addition & 0 deletions backend/app/Http/Request/Account/CreateAccountRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public function rules(): array
'currency_code' => [Rule::in(array_values($currencies))],
'locale' => ['nullable', Rule::in(Locale::getSupportedLocales())],
'invite_token' => ['nullable', 'string'],
'marketing_opt_in' => 'boolean|nullable',
];
}
}
1 change: 1 addition & 0 deletions backend/app/Http/Request/User/UpdateMeRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public function rules(): array
'confirmed',
Password::min(8)
],
'marketing_opt_in' => 'boolean|nullable',
];
}
}
1 change: 1 addition & 0 deletions backend/app/Resources/User/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public function toArray(Request $request): array
'is_email_verified' => $this->getEmailVerifiedAt() !== null,
'has_pending_email_change' => $this->getPendingEmail() !== null,
'locale' => $this->getLocale(),
'marketing_opted_in_at' => $this->getMarketingOptedInAt(),
$this->mergeWhen($isImpersonating, [
'is_impersonating' => true,
'impersonator_id' => $impersonatorId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public function handle(CreateAccountDTO $accountData): AccountDomainObject
'timezone' => $this->getTimezone($accountData),
'email_verified_at' => $isSaasMode ? null : now()->toDateTimeString(),
'locale' => $accountData->locale,
'marketing_opted_in_at' => $accountData->marketing_opt_in ? now()->toDateTimeString() : null,
]);

$this->accountUserAssociationService->associate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public function __construct(
public readonly ?string $timezone = null,
public readonly ?string $currency_code = null,
public readonly ?string $invite_token = null,
public readonly bool $marketing_opt_in = false,
)
{
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ private function acceptInvitation(AcceptInvitationDTO $invitationData): void
'password' => $this->hasher->make($invitationData->password),
'timezone' => $invitationData->timezone,
'email_verified_at' => now(),
'marketing_opted_in_at' => $invitationData->marketing_opt_in ? now()->toDateTimeString() : null,
],
where: [
'id' => $userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ public function __construct(
public readonly string $invitation_token,
public readonly string $first_name,
public readonly ?string $last_name = null,
public readonly string $password,
public readonly string $timezone,
public readonly string $password = '',
public readonly string $timezone = '',
public readonly bool $marketing_opt_in = false,
)
{
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public function __construct(
public readonly ?string $password,
public readonly ?string $current_password,
public readonly ?string $locale,
public readonly ?bool $marketing_opt_in = null,
)
{
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ public function handle(UpdateMeDTO $updateUserData): UserDomainObject
}
}

if ($updateUserData->marketing_opt_in !== null) {
$updateArray['marketing_opted_in_at'] = $updateUserData->marketing_opt_in
? now()->toDateTimeString()
: null;
}

$this->userRepository->updateWhere(
attributes: $updateArray,
where: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('users', static function (Blueprint $table) {
$table->timestamp('marketing_opted_in_at')->nullable()->after('locale');
});
}

public function down(): void
{
Schema::table('users', static function (Blueprint $table) {
$table->dropColumn('marketing_opted_in_at');
});
}
};
1 change: 1 addition & 0 deletions frontend/src/api/user.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface UserMeRequest {
password_confirmation: string;
password_current: string;
locale: string;
marketing_opt_in?: boolean;
}

export interface UpdateUserRequest {
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/components/routes/auth/AcceptInvitation/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Anchor, Button, PasswordInput, Select, Switch, TextInput} from "@mantine/core";
import {Anchor, Button, Checkbox, PasswordInput, Select, TextInput} from "@mantine/core";
import {t, Trans} from "@lingui/macro";
import {timezones} from "../../../../../data/timezones.ts";
import {useNavigate, useParams} from "react-router";
Expand All @@ -24,6 +24,7 @@ const AcceptInvitation = () => {
timezone: '',
password_confirmation: '',
terms: false,
marketing_opt_in: true,
},
validate: {
first_name: hasLength({min: 1, max: 50}, t`First name must be between 1 and 50 characters`),
Expand Down Expand Up @@ -135,7 +136,7 @@ const AcceptInvitation = () => {
/>
</div>

<Switch
<Checkbox
{...form.getInputProps('terms', {type: 'checkbox'})}
label={(
<Trans>
Expand All @@ -150,6 +151,12 @@ const AcceptInvitation = () => {
)}
/>

<Checkbox
mb="md"
{...form.getInputProps('marketing_opt_in', {type: 'checkbox'})}
label={<Trans>Receive product updates from {getConfig("VITE_APP_NAME", "Hi.Events")}.</Trans>}
/>

<Button
color="secondary.5"
fullWidth
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/components/routes/auth/Register/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Button, PasswordInput, SimpleGrid, TextInput} from "@mantine/core";
import {Button, Checkbox, PasswordInput, SimpleGrid, TextInput} from "@mantine/core";
import {hasLength, isEmail, matchesField, useForm} from "@mantine/form";
import {RegisterAccountRequest} from "../../../../types.ts";
import {useFormErrorResponseHandler} from "../../../../hooks/useFormErrorResponseHandler.tsx";
Expand Down Expand Up @@ -29,6 +29,7 @@ export const Register = () => {
locale: getClientLocale(),
invite_token: '',
currency_code: getUserCurrency(),
marketing_opt_in: true,
},
validate: {
password: hasLength({min: 8}, t`Password must be at least 8 characters`),
Expand Down Expand Up @@ -118,6 +119,13 @@ export const Register = () => {
{...form.getInputProps('timezone')}
type="hidden"
/>

<Checkbox
mb="md"
{...form.getInputProps('marketing_opt_in', {type: 'checkbox'})}
label={<Trans>Receive product updates from {getConfig("VITE_APP_NAME", "Hi.Events")}.</Trans>}
/>

<Button color="secondary.5" type="submit" fullWidth disabled={mutate.isPending}>
{mutate.isPending ? t`Working...` : t`Register`}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
.tabWrapper {
padding: var(--hi-spacing-xl);
}

.fieldsetLegend {
display: flex;
align-items: center;
gap: 0.5rem;
}
}

.emailNotVerified {
Expand Down
117 changes: 76 additions & 41 deletions frontend/src/components/routes/profile/ManageProfile/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {Card} from "../../../common/Card";
import {useForm, UseFormReturnType} from "@mantine/form";
import {useGetMe} from "../../../../queries/useGetMe.ts";
import {Alert, Button, NativeSelect, PasswordInput, Select, Tabs, TextInput} from "@mantine/core";
import {Alert, Button, Checkbox, NativeSelect, PasswordInput, Select, Tabs, TextInput} from "@mantine/core";
import classes from "./ManageProfile.module.scss";
import {useEffect, useState} from "react";
import {IconInfoCircle, IconPassword, IconUser} from "@tabler/icons-react";
import {IconInfoCircle, IconMail, IconPassword, IconUser, IconWorld} from "@tabler/icons-react";
import {timezones} from "../../../../../data/timezones.ts";
import {useUpdateMe} from "../../../../mutations/useUpdateMe.ts";
import {showError, showSuccess} from "../../../../utilites/notifications.tsx";
Expand All @@ -14,6 +14,9 @@ import {useFormErrorResponseHandler} from "../../../../hooks/useFormErrorRespons
import {t, Trans} from "@lingui/macro";
import {useResendEmailConfirmation} from "../../../../mutations/useResendEmailConfirmation.ts";
import {localeToFlagEmojiMap, localeToNameMap, SupportedLocales} from "../../../../locales.ts";
import {Fieldset} from "../../../common/Fieldset";
import {InputGroup} from "../../../common/InputGroup";
import {getConfig} from "../../../../utilites/config.ts";

const localeSelectData = Object.keys(localeToNameMap).map(locale => ({
value: locale,
Expand All @@ -34,6 +37,7 @@ export const ManageProfile = () => {
email: me?.email,
timezone: me?.timezone,
locale: me?.locale,
marketing_opt_in: me?.marketing_opted_in_at !== null,
},
});

Expand All @@ -52,6 +56,7 @@ export const ManageProfile = () => {
email: me?.email,
timezone: me?.timezone,
locale: me?.locale,
marketing_opt_in: me?.marketing_opted_in_at !== null,
});
}, [me]);

Expand Down Expand Up @@ -136,46 +141,76 @@ export const ManageProfile = () => {
<form
onSubmit={profileForm.onSubmit((values) => handleProfileFormSubmit(values, profileForm))}>
<fieldset disabled={isFetching}>
<TextInput required {...profileForm.getInputProps('first_name')}
label={t`First Name`}/>
<TextInput required {...profileForm.getInputProps('last_name')}
label={t`Last Name`}/>
<TextInput required {...profileForm.getInputProps('email')} label={t`Email`}/>
{(me && !me.is_email_verified && !emailConfirmationResent) && (
<Alert variant="light" mb={10}
title={t`Email not verified`} icon={<IconInfoCircle/>}>
<p>{t`Please verify your email address to access all features`}</p>
<Button size={'xs'} onClick={handleEmailConfirmationResend}>
{resendEmailConfirmationMutation.isPending ? t`Resending...` : t`Resend email confirmation`}
</Button>
</Alert>
)}

{emailConfirmationResent && (
<Alert variant="light" mb={10} color="green"
title={t`Email confirmation resent`} icon={<IconInfoCircle/>}>
<p>{t`Please check your email to confirm your email address`}</p>
</Alert>
)}

<Select
required
searchable
data={timezones}
{...profileForm.getInputProps('timezone')}
label={t`Timezone`}
placeholder={t`UTC`}
/>

<NativeSelect
required
data={localeSelectData}
value={profileForm.values.locale || ''}
onChange={(e) => profileForm.setFieldValue('locale', e.target.value)}
label={t`Language`}
/>
<Fieldset legend={
<span className={classes.fieldsetLegend}>
<IconUser size={16}/>
{t`Personal Information`}
</span>
}>
<InputGroup>
<TextInput required {...profileForm.getInputProps('first_name')}
label={t`First Name`}/>
<TextInput required {...profileForm.getInputProps('last_name')}
label={t`Last Name`}/>
</InputGroup>
<TextInput required {...profileForm.getInputProps('email')} label={t`Email`}/>
{(me && !me.is_email_verified && !emailConfirmationResent) && (
<Alert variant="light" mt={10}
title={t`Email not verified`} icon={<IconInfoCircle/>}>
<p>{t`Please verify your email address to access all features`}</p>
<Button size={'xs'} onClick={handleEmailConfirmationResend}>
{resendEmailConfirmationMutation.isPending ? t`Resending...` : t`Resend email confirmation`}
</Button>
</Alert>
)}

<Button fullWidth loading={mutation.isPending}
{emailConfirmationResent && (
<Alert variant="light" mt={10} color="green"
title={t`Email confirmation resent`} icon={<IconInfoCircle/>}>
<p>{t`Please check your email to confirm your email address`}</p>
</Alert>
)}
</Fieldset>

<Fieldset mt={20} legend={
<span className={classes.fieldsetLegend}>
<IconWorld size={16}/>
{t`Regional Settings`}
</span>
}>
<InputGroup>
<Select
required
searchable
data={timezones}
{...profileForm.getInputProps('timezone')}
label={t`Timezone`}
placeholder={t`UTC`}
/>

<NativeSelect
required
data={localeSelectData}
value={profileForm.values.locale || ''}
onChange={(e) => profileForm.setFieldValue('locale', e.target.value)}
label={t`Language`}
/>
</InputGroup>
</Fieldset>

<Fieldset mt={20} legend={
<span className={classes.fieldsetLegend}>
<IconMail size={16}/>
{t`Communication Preferences`}
</span>
}>
<Checkbox
{...profileForm.getInputProps('marketing_opt_in', {type: 'checkbox'})}
label={<Trans>Receive product updates from {getConfig("VITE_APP_NAME", "Hi.Events")}.</Trans>}
/>
</Fieldset>

<Button fullWidth loading={mutation.isPending} mt="lg"
type={'submit'}>{t`Update profile`}</Button>
</fieldset>
</form>
Expand Down
Loading
Loading