Rework onboarding

Close #435

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-11-13 13:39:52 +01:00
parent 347448700d
commit 223512f8ae
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
14 changed files with 308 additions and 100 deletions

View file

@ -126,3 +126,16 @@ a.list-item {
background-color: $list-item-hover-background-color;
cursor: pointer;
}
.setting-title {
margin-top: 2rem;
margin-bottom: 1rem;
h2 {
display: inline;
background: $secondary;
padding: 2px 7.5px;
text-transform: uppercase;
font-size: 1.25rem;
}
}

View file

@ -0,0 +1,70 @@
<template>
<div class="section container">
<div class="setting-title">
<h2>{{ $t("Profiles and federation") }}</h2>
</div>
<div>
<p class="content">
{{
$t(
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want."
)
}}
</p>
<hr />
<p class="content">
<span>
{{
$t(
"Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere."
)
}}
</span>
<span
v-html="
$t(
'This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.',
{
domain,
instanceName: config.name,
}
)
"
/>
</p>
<hr />
<p class="content">
{{
$t(
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:"
)
}}
</p>
<div class="has-text-centered">
<code>{{ `${currentActor.preferredUsername}@${domain}` }}</code>
</div>
</div>
</div>
</template>
<script lang="ts">
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { CONFIG } from "@/graphql/config";
import { IPerson } from "@/types/actor";
import { IConfig } from "@/types/config.model";
import { Component, Vue } from "vue-property-decorator";
@Component({
apollo: {
config: CONFIG,
currentActor: CURRENT_ACTOR_CLIENT,
},
})
export default class ProfileOnboarding extends Vue {
config!: IConfig;
currentActor!: IPerson;
domain = window.location.hostname;
}
</script>

View file

@ -25,41 +25,30 @@
</b-checkbox>
</div>
<p>{{ $t("To activate more notifications, head over to the notification settings.") }}</p>
<div class="has-text-centered">
<router-link
:to="{ name: RouteName.NOTIFICATIONS }"
class="button is-primary is-outlined"
>{{ $t("Manage my notifications") }}</router-link
>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { Component } from "vue-property-decorator";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user";
import { ICurrentUser } from "../../types/current-user.model";
import RouteName from "../../router/name";
@Component({
apollo: {
loggedUser: USER_SETTINGS,
},
})
export default class NotificationsOnboarding extends Vue {
loggedUser!: ICurrentUser;
import { mixins } from "vue-class-component";
import Onboarding from "../../mixins/onboarding";
@Component
export default class NotificationsOnboarding extends mixins(Onboarding) {
notificationOnDay = true;
RouteName = RouteName;
mounted(): void {
this.doUpdateSetting({
notificationOnDay: true,
notificationEachWeek: false,
notificationBeforeEvent: false,
});
}
async updateSetting(variables: Record<string, unknown>): Promise<void> {
try {
await this.$apollo.mutate<{ setUserSettings: string }>({
mutation: SET_USER_SETTINGS,
variables,
});
this.doUpdateSetting(variables);
} catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" });
}

View file

@ -4,6 +4,29 @@
<div class="setting-title">
<h2>{{ $t("Settings") }}</h2>
</div>
<div>
<h3>{{ $t("Language") }}</h3>
<p>
{{
$t(
"This setting will be used to display the website and send you emails in the correct language."
)
}}
</p>
<div class="has-text-centered">
<b-select
:loading="!config || !loggedUser"
v-model="locale"
:placeholder="$t('Select a language')"
>
<option v-for="(language, lang) in langs" :value="lang" :key="lang">
{{ language }}
</option>
</b-select>
</div>
</div>
<div>
<h3>{{ $t("Timezone") }}</h3>
<p>
{{
@ -13,42 +36,68 @@
}}
{{
$t("Your timezone was detected as {timezone}.", {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timezone,
})
}}
<b-message type="is-danger" v-if="!$apollo.loading && !supportedTimezone">
{{ $t("Your timezone {timezone} isn't supported.", { timezone }) }}
</b-message>
</p>
<div class="has-text-centered">
<router-link :to="{ name: RouteName.PREFERENCES }" class="button is-primary is-outlined">{{
$t("Manage my settings")
}}</router-link>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user";
import { ICurrentUser } from "../../types/current-user.model";
import RouteName from "../../router/name";
import { Component, Watch } from "vue-property-decorator";
import { TIMEZONES } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { saveLocaleData } from "@/utils/auth";
import { mixins } from "vue-class-component";
import Onboarding from "@/mixins/onboarding";
import { UPDATE_USER_LOCALE } from "../../graphql/user";
import langs from "../../i18n/langs.json";
@Component({
apollo: {
loggedUser: USER_SETTINGS,
config: TIMEZONES,
},
})
export default class SettingsOnboarding extends Vue {
loggedUser!: ICurrentUser;
export default class SettingsOnboarding extends mixins(Onboarding) {
config!: IConfig;
notificationOnDay = true;
RouteName = RouteName;
locale: string | null = this.$i18n.locale;
async updateSetting(variables: Record<string, unknown>): Promise<void> {
await this.$apollo.mutate<{ setUserSettings: string }>({
mutation: SET_USER_SETTINGS,
variables,
langs: Record<string, string> = langs;
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
mounted(): void {
this.doUpdateLocale(this.$i18n.locale);
this.doUpdateSetting({ timezone: this.timezone });
}
@Watch("locale")
async updateLocale(): Promise<void> {
if (this.locale) {
this.doUpdateLocale(this.locale);
saveLocaleData(this.locale);
}
}
private async doUpdateLocale(locale: string): Promise<void> {
await this.$apollo.mutate({
mutation: UPDATE_USER_LOCALE,
variables: {
locale,
},
});
}
get supportedTimezone(): boolean {
return this.config && this.config.timezones.includes(this.timezone);
}
}
</script>
<style lang="scss" scoped>

View file

@ -2,7 +2,7 @@
"Please do not use it in any real way.": "Please do not use it in any real way.",
"A user-friendly, emancipatory and ethical tool for gathering, organising, and mobilising.": "A user-friendly, emancipatory and ethical tool for gathering, organising, and mobilising.",
"A validation email was sent to {email}": "A validation email was sent to {email}",
"Abandon edition": "Abandon edition",
"Abandon editing": "Abandon editing",
"About Mobilizon": "About Mobilizon",
"About this event": "About this event",
"About this instance": "About this instance",
@ -43,7 +43,6 @@
"Cancel my participation…": "Cancel my participation…",
"Cancel": "Cancel",
"Cancelled: Won't happen": "Cancelled: Won't happen",
"Category": "Category",
"Change my email": "Change my email",
"Change my identity…": "Change my identity…",
"Change my password": "Change my password",
@ -430,8 +429,6 @@
"Assigned to": "Assigned to",
"Due on": "Due on",
"Organizers": "Organizers",
"Hide the organizer": "Hide the organizer",
"Don't show @{organizer} as event host alongside @{group}": "Don't show @{organizer} as event host alongside @{group}",
"You need to create the group before you create an event.": "You need to create a group before you create an event.",
"This identity is not a member of any group.": "This identity is not a member of any group.",
"(Masked)": "(Masked)",
@ -450,7 +447,6 @@
"Website": "Website",
"Actor": "Actor",
"Text": "Text",
"All group members and other eventual server admins will still be able to view this information.": "All group members and other eventual server admins will still be able to view this information.",
"Upcoming events": "Upcoming events",
"View all upcoming events": "View all upcoming events",
"Resources": "Resources",
@ -509,10 +505,8 @@
"{moderator} deleted an event named \"{title}\"": "{moderator} deleted an event named \"{title}\"",
"Register on this instance": "Register on this instance",
"To activate more notifications, head over to the notification settings.": "To activate more notifications, head over to the notification settings.",
"Manage my notifications": "Manage my notifications",
"We use your timezone to make sure you get notifications for an event at the correct time.": "We use your timezone to make sure you get notifications for an event at the correct time.",
"Your timezone was detected as {timezone}.": "Your timezone was detected as {timezone}.",
"Manage my settings": "Manage my settings",
"Let's define a few settings": "Let's define a few settings",
"All good, let's continue!": "All good, let's continue!",
"Login status": "Login status",
@ -797,5 +791,14 @@
"The only way for your group to get new members is if an admininistrator invites them.": "The only way for your group to get new members is if an admininistrator invites them.",
"Redirecting to content…": "Redirecting to content…",
"This URL is not supported": "This URL is not supported",
"This group is invite-only": "This group is invite-only"
"This group is invite-only": "This group is invite-only",
"This setting will be used to display the website and send you emails in the correct language.": "This setting will be used to display the website and send you emails in the correct language.",
"Previous": "Previous",
"Next": "Next",
"Your timezone {timezone} isn't supported.": "Your timezone {timezone} isn't supported.",
"Profiles and federation": "Profiles and federation",
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.": "Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.",
"Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere.": "Mobilizon is a federated software, meaning you can interact - depending on your admin federation settings - with content from other instances, such as joining groups or events that were created elsewhere.",
"This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.": "This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.",
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:"
}

View file

@ -24,7 +24,6 @@
"A user-friendly, emancipatory and ethical tool for gathering, organising, and mobilising.": "Un outil convivial, émancipateur et éthique pour se rassembler, s'organiser et se mobiliser.",
"A validation email was sent to {email}": "Un email de validation a été envoyé à {email}",
"API": "API",
"Abandon edition": "Abandonner la modification",
"About": "À propos",
"About Mobilizon": "À propos de Mobilizon",
"About this event": "À propos de cet évènement",
@ -879,5 +878,15 @@
"{profile} (by default)": "{profile} (par défault)",
"{title} ({count} todos)": "{title} ({count} todos)",
"{username} was invited to {group}": "{username} a été invité à {group}",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"This setting will be used to display the website and send you emails in the correct language.": "Ce paramètre sera utilisé pour l'affichage du site et pour vous envoyer des courriels dans la bonne langue.",
"Abandon editing": "Abandonner la modification",
"Previous": "Précédent",
"Next": "Suivant",
"Your timezone {timezone} isn't supported.": "Votre fuseau horaire {timezone} n'est pas supporté.",
"Profiles and federation": "Profils et fédération",
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.": "Mobilizon utilise un système de profils pour compartimenter vos activités. Vous pourrez créer autant de profils que vous voulez.",
"Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere.": "Mobilizon est un logiciel fédéré, ce qui signifie que vous pouvez interagir - en fonction des paramètres de fédération de votre administrateur·ice - avec du contenu d'autres instances, comme par exemple rejoindre des groupes ou des événements ayant été créés ailleurs.",
"This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.": "Cette instance, <b>{instanceName} ({domain})</b>, héberge votre profil, donc notez bien son nom.",
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "Si l'on vous demande votre identité fédérée, elle est composée de votre nom d'utilisateur·ice et de votre instance. Par exemple, l'identité fédérée de votre premier profil est :"
}

View file

@ -0,0 +1,22 @@
import { SET_USER_SETTINGS, USER_SETTINGS } from "@/graphql/user";
import RouteName from "@/router/name";
import { ICurrentUser } from "@/types/current-user.model";
import { Component, Vue } from "vue-property-decorator";
@Component({
apollo: {
loggedUser: USER_SETTINGS,
},
})
export default class Onboarding extends Vue {
loggedUser!: ICurrentUser;
RouteName = RouteName;
protected async doUpdateSetting(variables: Record<string, unknown>): Promise<void> {
await this.$apollo.mutate<{ setUserSettings: string }>({
mutation: SET_USER_SETTINGS,
variables,
});
}
}

View file

@ -110,6 +110,20 @@ const router = new Router({
component: () =>
import(/* webpackChunkName: "ProviderValidation" */ "@/views/User/ProviderValidation.vue"),
},
{
path: "/welcome/:step?",
name: RouteName.WELCOME_SCREEN,
component: () =>
import(/* webpackChunkName: "WelcomeScreen" */ "@/views/User/SettingsOnboard.vue"),
meta: { requiredAuth: true },
props: (route) => {
const step = Number.parseInt(route.params.step, 10);
if (Number.isNaN(step)) {
return 1;
}
return { step };
},
},
{
path: "/404",
name: RouteName.PAGE_NOT_FOUND,

View file

@ -17,6 +17,7 @@ enum GlobalRouteName {
GLOSSARY = "GLOSSARY",
INTERACT = "INTERACT",
RULES = "RULES",
WELCOME_SCREEN = "WELCOME_SCREEN",
}
// Hack to merge enums

View file

@ -64,7 +64,7 @@
</form>
<div v-if="validationSent && !userAlreadyActivated">
<b-message type="is-success" closable="false">
<b-message type="is-success" :closable="false">
<h2 class="title">
{{
$t("Your account is nearly ready, {username}", {

View file

@ -850,7 +850,7 @@ export default class EditEvent extends Vue {
this.$buefy.dialog.confirm({
title,
message,
confirmText: this.$t("Abandon edition") as string,
confirmText: this.$t("Abandon editing") as string,
cancelText: this.$t("Continue editing") as string,
type: "is-warning",
hasIcon: true,

View file

@ -168,19 +168,18 @@
<b-message v-else type="is-danger">{{ $t("No events found") }}</b-message>
</section>
</div>
<settings-onboard v-else-if="config && loggedUser && loggedUser.settings == undefined" />
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { Component, Vue, Watch } from "vue-property-decorator";
import { IParticipant, Participant, ParticipantRole } from "../types/participant.model";
import { FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue";
import EventCard from "../components/Event/EventCard.vue";
import { CURRENT_ACTOR_CLIENT, LOGGED_USER_PARTICIPATIONS } from "../graphql/actor";
import { IPerson, Person } from "../types/actor";
import { ICurrentUser } from "../types/current-user.model";
import { ICurrentUser, IUser } from "../types/current-user.model";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user";
import RouteName from "../router/name";
import { IEvent } from "../types/event.model";
@ -386,6 +385,13 @@ export default class Home extends Vue {
viewEvent(event: IEvent): void {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
}
@Watch("loggedUser")
detectEmptyUserSettings(loggedUser: IUser): void {
if (loggedUser && loggedUser.id && loggedUser.settings === null) {
this.$router.push({ name: RouteName.WELCOME_SCREEN, params: { step: "1" } });
}
}
}
</script>

View file

@ -322,20 +322,6 @@ export default class AccountSettings extends Vue {
}
}
</script>
<style lang="scss">
.setting-title {
margin-top: 2rem;
margin-bottom: 1rem;
h2 {
display: inline;
background: $secondary;
padding: 2px 7.5px;
text-transform: uppercase;
font-size: 1.25rem;
}
}
</style>
<style lang="scss" scoped>
.cancel-button {

View file

@ -1,48 +1,94 @@
<template>
<div class="section container">
<h1 class="title">{{ $t("Let's define a few settings") }}</h1>
<b-steps v-model="realActualStepIndex" :has-navigation="false">
<b-step-item step="1" :label="$t('Settings')">
<settings-onboarding />
</b-step-item>
<b-step-item step="2" :label="$t('Participation notifications')">
<notifications-onboarding />
<section class="has-text-centered section">
<b-button @click="refresh()" type="is-success" size="is-big">
</b-step-item>
<b-step-item step="3" :label="$t('Profiles and federation')">
<profile-onboarding />
</b-step-item>
</b-steps>
<section class="has-text-centered section buttons">
<b-button
outlined
:disabled="realActualStepIndex < 1"
tag="router-link"
:to="{
name: RouteName.WELCOME_SCREEN,
params: { step: actualStepIndex - 1 },
}"
>
{{ $t("Previous") }}
</b-button>
<b-button
outlined
type="is-success"
v-if="realActualStepIndex < 2"
tag="router-link"
:to="{
name: RouteName.WELCOME_SCREEN,
params: { step: actualStepIndex + 1 },
}"
>
{{ $t("Next") }}
</b-button>
<b-button
v-if="realActualStepIndex >= 2"
type="is-success"
size="is-big"
tag="router-link"
:to="{ name: RouteName.HOME }"
>
{{ $t("All good, let's continue!") }}
</b-button>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { SET_USER_SETTINGS } from "../../graphql/user";
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { TIMEZONES } from "../../graphql/config";
import RouteName from "../../router/name";
import { IConfig } from "../../types/config.model";
@Component({
components: {
NotificationsOnboarding: () => import("../../components/Settings/NotificationsOnboarding.vue"),
SettingsOnboarding: () => import("../../components/Settings/SettingsOnboarding.vue"),
ProfileOnboarding: () => import("../../components/Account/ProfileOnboarding.vue"),
},
apollo: {
config: TIMEZONES,
},
})
export default class SettingsOnboard extends Vue {
@Prop({ required: false, default: 1, type: Number }) step!: number;
config!: IConfig;
@Watch("config")
async timezoneLoaded() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (this.config && this.config.timezones.includes(timezone)) {
await this.$apollo.mutate<{ setUserSettings: string }>({
mutation: SET_USER_SETTINGS,
variables: {
timezone,
},
});
RouteName = RouteName;
actualStepIndex = this.step;
@Watch("step")
updateActualStep(): void {
if (this.step) {
this.actualStepIndex = this.step;
}
}
refresh() {
location.reload();
get realActualStepIndex(): number {
return this.actualStepIndex - 1;
}
}
</script>
<style scoped lang="scss">
.section.container {
.has-text-centered.section.buttons {
justify-content: center;
}
}
</style>